Почему ни одна Python-библиотека для конфигурации не закрывает все задачи


Привет, Хабр! На связи Николай Видов, тимлид команды чат-ботов в Т, пишу на Python с 2017 года. За это время я успел поработать с конфигами во всех видах: INI-файлы в legacy-проектах, переменные окружения в docker-контейнерах, YAML с шаблонами в Helm, pydantic-модели в свежих сервисах, Vault и Consul в инфраструктуре покрупнее.
Эта статья — попытка разложить все по полочкам: что когда появилось, какие проблемы решало и почему ни один подход так и не закрыл вопрос окончательно. В предыдущей статье я разобрал, как индустрия пришла к современным подходам управления конфигурацией.
Пройдусь по четырем заметным инструментам: python-decouple, Dynaconf, Hydra, pydantic-settings. Покажу, попадают ли они в семь требований: много источников, мердж с контролем, типобезопасность, валидация значений, понятные ошибки, секреты на уровне схемы и отладка. В конце соберу сводный взгляд: кто что закрывает и где в инструментах явная дыра.
python-decouple
python-decouple решает узкую задачу: он читает конкретные значения настроек из внешних источников. По сути это одна функция config(), которая знает три источника (.env, .ini, переменные среды) и умеет приводить тип:
from decouple import config
DEBUG = config("DEBUG", default=False, cast=bool)
DATABASE_URL = config("DATABASE_URL")
PORT = config("PORT", cast=int)Поиск источника прозрачен: библиотека сначала ищет settings.ini, потом .env, потом обращается к os.environ. Это удобно, когда уже есть legacy .ini от старого проекта и хочется постепенно переезжать на .env, не переписывая все за раз. Через cast можно прокинуть любую функцию-конвертер — int, float, bool или свою (cast=lambda v: v.split(",") для списков). Для частого случая со списками есть готовый хелпер Csv(), а bool из коробки понимает "yes"/"no", "1"/"0" и "true"/"false". На этом список источников заканчивается: YAML, TOML или Docker secrets библиотека не читает. Валидацию значений формально можно навесить через тот же cast: функция-конвертер вправе не только привести тип, но и проверить, что порт попадает в 1—65535, а URL начинается с http. Это ручная работа для каждого поля отдельно, а не валидация как часть единой схемы.
Исторически python-decouple вырос как замена os.environ.get() в Django-проектах. Это до сих пор его основная аудитория: Django-туториалы охотно его рекомендуют и для проекта на 5—10 настроек этого достаточно. Но как только структура конфига отрастает дальше, ограничения проявляются разом. Схема не объявляется в одном месте — все вызовы config() разбросаны по импортам:
# settings/database.py
DATABASE_URL = config("DATABASE_URL")
# settings/cache.py
REDIS_URL = config("REDIS_URL", default="redis://localhost:6379/0")
# settings/auth.py
JWT_SECRET = config("JWT_SECRET")
JWT_EXPIRES = config("JWT_EXPIRES", default=3600, cast=int)Найти полный список переменных, которые ждет приложение, можно только через grep config( по всему репозиторию. Никакой схемы-контракта в одном месте нет — а значит, нет и типобезопасности: IDE не подскажет поле, mypy не проверит опечатку и обнаружится она в рантайме, тем самым AttributeError в проде, которого и хотелось избежать проверкой на старте.
Вложенных структур нет в принципе — для database.primary.host придется плоско выписать DATABASE_PRIMARY_HOST и затем собирать структуру руками. Мерж нескольких источников не предусмотрен: библиотека читает один источник правды. python-decouple не умеет переопределять значения, например задать значения по умолчанию в .ini, а затем перекрыть их из .env. Значит, и говорить о стратегиях разрешения конфликтов (последний побеждает, первый побеждает, упасть с ошибкой) здесь не приходится.
Остального в наборе тоже нет: при отсутствии переменной прилетит UndefinedValueError с именем ключа, но без файла и строки; секрет вроде JWT_SECRET остается обычной строкой без типа SecretStr и спокойно светится в repr(); а узнать, откуда конкретное значение приехало — из .ini, .env или окружения, — штатно нельзя.
Я держу python-decouple в голове для одноразовых скриптов и небольших утилит, где конфиг — это пять переменных и единственный .env-файл. Как только проект отращивает вложенные настройки или окружения сложнее «локально/прод», обычно приходится мигрировать на что-то другое. И это нормально: python-decouple честно делает то, что обещает, и не претендует на большее. Сравнивать его с остальными участниками обзора имеет смысл только в его собственном жанре — простых скриптов.
Dynaconf
Dynaconf делает ставку на гибкость и runtime-настройку. Он читает YAML, TOML, JSON, INI, .env, python-файлы, подключает Vault и Redis через плагины, поддерживает динамическую перезагрузку. По части источников это самый богатый участник обзора: большинство форматов читаются из коробки, а Vault и Redis подключаются плагинами — «много источников» закрыто с запасом. На входе — список файлов, на выходе — объект, у которого можно спросить что угодно:
from dynaconf import Dynaconf
settings = Dynaconf(settings_files=["config.toml"])
print(settings.HOST)
print(settings.PORT)Здесь начинается философское расхождение. Dynaconf принципиально не требует объявлять структуру. settings.HOSTT (с опечаткой) не упадет — просто вернет пустоту. IDE не подскажет, потому что нечему подсказывать: список полей нигде не зафиксирован. mypy бессилен по той же причине. Схема не живет в коде, а значит, нет и контракта, который проверяется на старте. Конфигурация валидна ровно до того момента, пока кто-то не обратится к полю.
Для своего класса задач это фича, а не баг. Если мы делаем админку, где админ через UI может добавлять произвольные настройки, или платформу, где плагины расширяют конфиг, — отсутствие схемы уместно. Если же мы пишем обычный сервис, где набор настроек фиксирован и известен заранее, отсутствие схемы превращается в постоянный источник «ой, а почему оно None?».
Валидация в Dynaconf существует, но как отдельная пристройка к зданию:
from dynaconf import Dynaconf, Validator
settings = Dynaconf(
settings_files=["config.toml"],
validators=[
Validator("PORT", gte=1, lte=65535),
Validator("HOST", must_exist=True),
],
)
settings.validators.validate()Валидация есть и умеет проверять диапазоны и обязательность полей, но живет снаружи схемы. Валидатор ссылается на поле строкой. Добавил новое поле — забыл валидатор, никто не напомнит. Это не недостаток реализации, это цена за работу без схемы: раз структуры нет, валидация не может быть ее частью. Тот же паттерн в мерже: настройки наследуются через специальные ключи в самих файлах конфигурации:
```toml
[databases]
dynaconf_merge = true
port = 5433
```Инфраструктурная логика просачивается в данные. Каждый, кто правит конфиг, должен знать про dynaconf_merge — иначе случайно затрет вложенную секцию. Причем стратегию мержа задают сами данные, а не код: чтобы дополнить список, а не заменить его, нужно прописать нужный ключ прямо в файле. А привычного правила «при конфликте — падать с ошибкой» здесь и вовсе нет.
Три оставшихся критерия ложатся по-разному. Аудит источников — сильная сторона: dynaconf list -o или инспекция объекта показывают, из какого файла и слоя приехало значение, так что «детектива» в проде удается избежать. С секретами скромнее: отдельного типа вроде SecretStr, который сам бы маскировал значение в repr(), в схеме нет, потому что нет и самой схемы, чувствительные поля защищаются на уровне источника (тот же Vault), а не модели. Ошибки же наследуют главную проблему бессхемного подхода: пропущенное поле — это не падение с указанием файла и строки, а тихий None, который выстрелит где-то дальше по коду.
Когда Dynaconf оправдан: нам нужны удаленные источники (Vault, Redis) из коробки, Jinja-шаблоны в значениях, переключение окружений секциями [development]/[production] в одном файле, экосистема плагинов и CLI-утилиты. Это серьезная инфраструктура, и собрать ее с нуля долго.
Когда неоправдан: хотим, чтобы конфиг был типизированным контрактом, а IDE и mypy ловили опечатки до запуска.
Hydra
Hydra от Meta Research — это фреймворк. В отличие от обычной библиотеки, которую мы вызываем из своего кода, Hydra работает наоборот: она берет управление на себя. Hydra оборачивает точку входа приложения и сама запускает наш код внутри своего жизненного цикла. Для ML-эксперимента это удобно: декоратор @hydra.main решает за нас кучу мелких задач:
@hydra.main(config_path="conf", config_name="config")
def app(cfg: DictConfig) -> None:
print(cfg.database.host)Hydra сама создаст директорию outputs/YYYY-MM-DD/HH-MM-SS/, перенаправит туда логи, поменяет рабочую директорию процесса, склеит конфиги из нескольких файлов через defaults list, прокинет CLI-оверрайды. Мерж у Hydra продуманный: defaults list композирует конфиги предсказуемо, а оверрайды с командной строки ложатся поверх — правда, вся эта механика тоже заточена под перебор экспериментов, а не под слияние «дефолты + локальный .env + секреты». Для запуска 50 экспериментов с разными гиперпараметрами это ровно то, что нужно.
Для веб-сервиса, у которого точка входа — uvicorn, а рабочая директория — место, где лежат шаблоны и статика, дизайн Hydra встает поперек горла. Относительные пути сломаются, конфигурация логирования будет перетерта, в файловой системе появятся outputs/ от каждого запуска.
Hydra читает только YAML. Переменные окружения доступны через резолвер OmegaConf ${oc.env:VAR}, но .env, TOML, JSON или INI не работают без сторонних плагинов. Так что «много источников» здесь закрыто наполовину: YAML и переменные среды — сразу, прочие форматы — через плагины.
Возвращаемый объект — OmegaConf.DictConfig, специальный контейнер, который выглядит как dataclass, но им не является. Автокомплит работает ограниченно, mypy — частично. Типы поддерживаются только базовые: примитивы, enum, контейнеры. Union, datetime, IPv4Address, SecretStr уже не поддерживаются. Валидация — только проверка типов через OmegaConf:
# нет валидации значений
@dataclass
class Config:
port: int = 8080 # любой int принимается, даже -1
cfg = OmegaConf.structured(Config)Типобезопасность половинчатая (mypy и автокомплит работают частично), а валидации значений — «порт 1—65535». «URL с http» нет как класса: проверяется только тип, но не осмысленность. Сюда же попадает и критерий секретов: раз SecretStr из поддерживаемых типов выпадает, пометить поле как чувствительное прямо в схеме нельзя — пароль останется обычной строкой.
И еще одна вещь, которую важно держать в голове при выборе: Hydra — живой проект, но ее дизайн по-прежнему заточен под ML-эксперименты. Композиция конфигов, директории с результатами запусков, переопределения через командную строку, управление рабочей директорией — все это удобно для перебора гиперпараметров, но для веб-сервиса оказывается слишком тяжелым. Дело не в поддержке, а в том, что фреймворк навязывает свой способ жить точке входа приложения.
С отладкой и ошибками у Hydra как раз порядок, это наследие ее ML-природы. Аудит источников силен: --cfg job печатает итоговый конфиг, а --info показывает, из каких файлов и оверрайдов он собрался, так что вопрос «откуда приехало значение» решается штатно. Ошибки OmegaConf тоже осмысленные — при обращении к несуществующему ключу или несовпадении типа вы получите сообщение с dotted-path до поля (database.port), а не голый стектрейс. Правда, до координат во входном файле — какой YAML, какая строка — оно не доходит: путь указывается внутри конфига, а не в исходнике.
Hydra идеальна для ML-экспериментов со свипами гиперпараметров и инстанциацией объектов из конфига (hydra.utils.instantiate — это вообще отдельная фича). Для конфигурации приложения (веб, CLI, daemon) — нецелевое использование.
pydantic-settings
pydantic-settings — насколько мне известно, единственная по-настоящему типобезопасная библиотека для конфигурации в Python с массовой адопцией. Если Pydantic уже в проекте, pydantic-settings подключается естественно:
from pydantic_settings import BaseSettings
class Config(BaseSettings):
host: str
port: int
debug: bool = FalseТипы проверяются, IDE подсказывает поля, валидаторы Pydantic работают из коробки. Для типового сценария «прочитать переменные среды + опционально .env файл» это лучший выбор в экосистеме, и обсуждать тут нечего.
Сильные стороны, которых нет у других. Прежде чем разбирать ограничения, важно проговорить: у pydantic-settings есть набор возможностей, которых у конкурентов пока нет или которые подключаются у других через боль.
Встроенный CLI (
CliSettingsSource) превращает Settings-модель в CLI-приложение — с подкомандами, асинхронными обработчиками, автоматической справкой. Это удобно, когда инструмент должен работать и как сервис, и как CLI-утилита (миграции, бэкапы, разовые операции).
Чтение из pyproject.toml через
PyprojectTomlConfigSettingsSource— естественный путь для библиотек и CLI-инструментов, которые хотят складывать свои настройки в общий файл проекта, а не плодить еще один.
Вложенные secrets-директории (
NestedSecretsSettingsSource) — обработка Docker secrets стиля файлов с произвольной вложенностью.
Тесная интеграция с FastAPI, SQLModel, LangChain — конфиг можно прокидывать как зависимость, переиспользовать валидаторы Pydantic для request/response моделей, шарить
computed_field.
Валидаторы —
field_validator,model_validatorс режимамиmode="before"/mode="after",computed_field— закрывают практически любые сценарии валидации, какие могут понадобиться.
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings
class Config(BaseSettings):
host: str
port: int
use_ssl: bool = False
cert_path: str | None = None
@field_validator("port")
@classmethod
def port_in_range(cls, v: int) -> int:
if not (1 <= v <= 65535):
raise ValueError("port must be between 1 and 65535")
return v
@model_validator(mode="after")
def ssl_requires_cert(self) -> "Config":
if self.use_ssl and not self.cert_path:
raise ValueError("cert_path is required when use_ssl is True")
return selfОграничения. В pydantic-settings v2 добавили встроенные JsonConfigSettingsSource, YamlConfigSettingsSource и TomlConfigSettingsSource. Но каждый нужно явно подключать через settings_customise_sources:
class Settings(BaseSettings):
model_config = SettingsConfigDict(toml_file="config.toml")
@classmethod
def settings_customise_sources(cls, settings_cls, **kwargs):
return (
kwargs["init_settings"],
kwargs["env_settings"],
TomlConfigSettingsSource(settings_cls),
)INI, JSON5, YAML 1.1, TOML 1.1 — не поддерживаются. Нет
skip_if_brokenдля отсутствующих файлов. Если файл указан, но его нет — нужно ловить исключение и принимать решение руками. Нетfield_groups, когда группа полей должна браться целиком из одного источника. Нет ENV expansion (${VAR:-default}) в файлах конфигурации — все подстановки приходится делать до загрузки или поверх.
Мерж источников — фиксированный приоритет: init → переменные среды → dotenv → secrets → значения по умолчанию. Ни стратегий, ни
per_field-правил, ни контроля конфликтов. Если хочется «списокallowed_hostsобъединять, а отдельные строки заменять» — это уже не штатно. Реализация любого такого сценария требует кастомногоSettingsSource-класса с переопределением_extract_field_infoи_field_is_complex. Работает, но не сказать, что это «коробочный сценарий».
Кастомные типы требуют реализации
__get_pydantic_core_schema__. Для большинства случаев это решается черезAnnotated[YourType, BeforeValidator(parser)]и проще, чем выглядит, но не нулевой объем кода. Смешение BaseSettings с другими базовыми классами (например, с миксинами) иногда вызывает конфликты метаклассов — Pydantic подменяет__init_subclass__и делает другие интрузивные вещи на уровне рантайма.
Производительность: миф для статичного конфига, реальность для динамического. Иногда можно услышать аргумент «pydantic медленнее dataclass, выбирайте dataclass». Для конфигурации это поверхностный аргумент. Микробенчмарки действительно показывают разницу на порядок: создание простой Pydantic-модели обходится в ~1,5 мкс, dataclass — в районе 200—300 нс (см бенчмарки ниже). Но конфиг приложения загружается один раз при старте, и эта разница теряется на фоне открытия файла, парсинга YAML/TOML, чтения переменных окружения. Если у вас веб-сервис, который живет неделями — оверхед на старте можно игнорировать.
Картина меняется в трех сценариях:
Холодный старт короткоживущих процессов. AWS Lambda, Cloud Functions, CLI-утилиты, периодические задачи в Kubernetes — везде, где процесс запускается заново ради короткой работы. Здесь время до полезной работы складывается из импорта зависимостей, инициализации логгера, загрузки конфига. На холодном старте Lambda один только import pydantic стоит десятки или сотни миллисекунд в зависимости от версии Python и pydantic (обсуждение import time в pydantic). Порядок легко проверить локально через
python -X importtime -c "import pydantic", и это уже заметно в SLA. Для конфига в 20 полей разница междуBaseSettingsи@dataclass— единицы миллисекунд, что не критично. Критичен сам факт затаскивания Pydantic в зависимости, если в проекте он больше нигде не нужен.
Горячая перезагрузка и динамическое обновление. Современные приложения все чаще не перезапускаются ради смены настроек. Размер пула, фича-флаги, уровни логирования, лимиты запросов — все это хочется крутить на лету. Если конфиг перечитывается каждые 30 секунд и в пиковом сценарии мы делаем это в 100 воркерах параллельно, то накопленная нагрузка на CPU уже видна на графиках. Pydantic в этом сценарии валидирует данные с нуля при каждом обновлении (у него нет кэширования валидации между вызовами), dataclass — только если прописали валидацию в
__post_init__, и тогда мы ее контролируем.
Ротация секретов через Vault и аналоги. Это частный случай предыдущего пункта, но с собственной спецификой. Если приложение подписано на изменения в Vault и реагирует на ротацию, факт перезагрузки секретов происходит регулярно — каждые несколько часов или даже минут, в зависимости от политики. Здесь же добавляется задержка сетевого вызова к Vault, которая на порядок больше любой валидации, так что разница между Pydantic и dataclass снова стирается, но накладные расходы pydantic-settings на повторное создание моделей в момент обновления остаются ненулевыми.
Производительность — не главная причина выбирать или не выбирать pydantic-settings. У него есть мощная система валидации, обширная экосистема, отличная интеграция с FastAPI — это весомые плюсы. Но если мы строим долгоживущий сервис с динамической конфигурацией и хотим тонко контролировать стоимость перезагрузки — стандартный dataclass дает больше управления. Потому что мы сами решаем, что и когда валидируется, и не платим за повторную обработку, когда ее можно избежать.
Ошибки. Сообщения от Pydantic информативны по содержанию:
validation error for Settings
port
Input should be a valid integer, unable to parse string as an integer
[type=int_parsing, input_value='abc', input_type=str]Но когда источников несколько, в сообщении нет указания, в каком файле лежит невалидное значение. Если у нас defaults.yaml, override.yaml и переменные среды одновременно задают port, искать виновника придется вручную.
В сумме: pydantic-settings — это нормальный выбор по умолчанию, когда один-два источника и Pydantic в проекте. Если упираемся в мерж нескольких файлов с правилами per-field, в отладку «откуда пришло значение» или в ошибки «valid integer, unable to parse 'abc' — но в каком из трех файлов оно лежит?» — это сигнал, что нужен другой инструмент.
Все библиотеки через призму семи требований
Если разложить рынок по семи критериям, картина видна сразу: что-то закрыто хорошо, что-то — частично, а несколько требований из семи рынок закрывает только наполовину. Пройдусь по каждому требованию отдельно.
Требование «Много источников». Смысл требования в том, чтобы не подключая отдельный адаптер под каждый формат читать разные источники из коробки: YAML для дефолтов, переменные среды для секретов, .env для локальных правок.
Здесь явный лидер — Dynaconf. Из коробки читает YAML, TOML, JSON, INI, .env, Python-модули. Через плагины подключаются Vault, Redis, AWS Secrets Manager.
Pydantic-settings штатно знает переменные среды, .env, JSON, YAML, TOML, но каждый дополнительный источник нужно явно подключать через settings_customise_sources. Hydra работает только с YAML, остальные форматы — через сторонние плагины.
python-decouple ограничен .env, .ini, и переменные среды и в этом критерии не претендует на универсальность.
Требование «Мерж с контролем». Важно управлять слиянием: выбрать стратегию при конфликте (побеждает последний / первый / падаем с ошибкой) и задавать ее точечно, для конкретных полей.
Здесь у рынка системная дыра. Pydantic-settings дает жесткий приоритет init > env > dotenv > secrets > defaults без возможности менять стратегию или задавать правила для отдельных полей.
Dynaconf поддерживает per-field merge через ключ dynaconf_merge = true, но прописывается он в самих файлах конфигурации, и каждый, кто их правит, должен знать про этот ключ. Hydra умеет склеивать конфиги через defaults list — мощно для ML, но без понятия «эта политика для этого поля». python-decouple мерж не предусматривает в принципе.
Требование типобезопасности. Смысл требования — описать структуру конфига прямо в коде, чтобы IDE подсказывала поля, mypy ловил опечатки, а ошибка вылезала на старте, а не в рантайме.
Pydantic-settings закрывает требование полностью: модель описывается в коде, IDE подсказывает поля, mypy ловит опечатки.
Hydra покрывает частично — через структурированные конфиги в OmegaConf: поля проверяются, но DictConfig — это все-таки не dataclass и часть инструментов работает с ним хуже.
Dynaconf и python-decouple типобезопасности не дают по дизайну — обращение к полю идет строкой.
Требование по валидации значений. Проверить тип — необходимо, но мало: int пропустит и порт -1, а строка — и «не-URL». Критерий про проверку самих значений — диапазонов, форматов, бизнес-правил — и про то, чтобы она была частью схемы, а не отдельной системой, которую забудешь навесить на новое поле.
Pydantic-settings — лидер: field_validator, model_validator, Annotated-валидаторы, готовая экосистема типов (PositiveInt, HttpUrl, EmailStr).
Dynaconf дает отдельную систему Validator(...), привязанную к строковым именам полей: рабочая, но физически отделена от схемы. Hydra ограничена проверкой типов через OmegaConf — диапазоны, регулярки, бизнес-правила писать вручную. python-decouple дает cast= для приведения типа и собственного кода для проверки значений.
Требование к понятным ошибкам. Хорошее сообщение об ошибке отвечает на два вопроса: что не так и где это чинить. Первое — человекочитаемый текст вместо стектрейса из парсера, второе — координаты в исходнике. Как выяснилось, с первым справляются все, а вот со вторым — никто.
Pydantic выдает информативное сообщение — Input should be a valid integer, Dynaconf и Hydra пишут стандартные исключения с типом и значением. А вот координат во входном файле нет ни у кого: какой из трех источников принес мусорное значение, в каком файле и на какой строке — пользователь выясняет руками.
Требование защиты секретов. Критерий про то, чтобы пометить пароль или токен прямо в модели специальным типом — и тогда он не утечет открытым текстом в repr(), логах или трейсе ошибки.
Самый продвинутый вариант сегодня — SecretStr в pydantic-settings: поле прячет значение в repr и логах. Это закрывает базовый сценарий, но требует, чтобы разработчик явно пометил поле. Забыл SecretStr — и значение полетело в лог как обычная строка. Автоматического срабатывания «по имени поля» или «по виду значения» нет. У Dynaconf, Hydra и python-decouple маскировки из коробки нет вовсе.
Требования к отладке. Речь не о невалидном конфиге — со значением все в порядке по типу и диапазону, просто оно не то, что ждали. Критерий про происхождение: инструмент должен сказать, откуда приехало port=9090 — из файла, переменной окружения или дефолта. Без такого аудита остается детектив с print(config) и пересборкой образа.
Самое непокрытое из семи требований. Dynaconf дает inspect_settings() — наиболее близкое к «откуда пришло значение», но в виде свободного словаря, а не структурированного отчета. Hydra пишет все примененное в outputs/ — полезно для воспроизводимости эксперимента, но не для ответа на вопрос «почему port=9090 в проде». Pydantic-settings и python-decouple отладочной информации не дают: получили объект — а дальше grep по коду.
Если свести в одну строку: рынок закрывает типобезопасность и валидацию (pydantic-settings) и универсальность источников (Dynaconf). Частично закрывает защиту секретов (только pydantic-settings и только по типу) и понятные ошибки (текст есть, координат во входном файле нет) и почти не закрывает контролируемый мерж и аудит источников. Эти провалы и стали личной мотивацией заняться своей библиотекой.
Расклад по жанрам задач получается такой:
Скрипт на 50 строк → python-decouple.
Удаленные источники + работа без схемы → Dynaconf.
Один-два источника с Pydantic в проекте → pydantic-settings.
ML-эксперимент → Hydra.
Нет готового инструмента для самого частого случая — сервиса, который живет годами. Ему нужно все сразу: несколько источников, умный мерж, понятные ошибки с указанием файла и строки, маскированные секреты и возможность проверить, откуда взялось значение. По отдельности это умеют разные инструменты. Все в одном — не умеет никто.
Мотивация: зачем еще одна библиотека
Призма семи требований показывает дыры в рынке абстрактно. Но до того, как я сел делать свою библиотеку, эти дыры выглядели иначе — как набор конкретных болей, которые я годами ловил из проекта в проект.
Боль: per-field merge (и согласованность связанных полей). Одно из больных мест по этой призме лично для меня — контролируемый мерж. У меня он годами проявлялся в двух связанных сценариях.
Первый сценарий: список (CORS-origins, allowed-hosts, монитор-пути) хочется объединять между источниками, а простые поля (URL базы, размер пула) — заменять по приоритету. Pydantic-settings дает фиксированный приоритет «все заменяется, последний победил». Dynaconf дает dynaconf_merge, но прописываемый в самих файлах конфигурации, а не в коде. Чтобы получить нужное поведение на pydantic-settings, нужно писать кастомный SettingsSource — десятки строк шаблонного кода ради одного поля.
Второй сценарий — частный случай той же истории. В defaults.yaml лежат связанные настройки primary БД (db_host, db_port, db_name, db_user). В staging.yaml их нужно переопределить целиком. Кто-то в спешке прописал только db_host, остальные забыл; ревью пропустил. Приложение стартует и подключается к staging-host:5432/production_db — частично переключенный конфиг, который никто не заметил, пока в стейджинг не прилетел запрос.
Дело не в валидаторах — каждое поле по отдельности валидно. Дело в согласованности по источникам: эти четыре поля — группа, либо все четыре приходят из одного источника, либо это ошибка. Среди рассмотренных популярных инструментов я не нашел коробочного способа выразить это декларативно и типизированно. На практике пишут свое на model_validator хрупко и многословно либо ловят это уже по факту в проде.
Обе боли — про одно и то же: разработчик хочет управлять мержем декларативно и в коде, а не через специальные ключи в данных или ручное склеивание.
Боль: «откуда оно вообще?». Очень частый сценарий дебага конфигов в моей практике — найти, откуда взялось конкретное значение. Конфиг собирается из трех-четырех источников: defaults.yaml, override.yaml, переменные окружения, секреты из Vault. И в какой-то момент я хочу понять, откуда именно пришел этот port=8080 или этот ValidationError.
Сценариев отладки здесь два, и они немного разные.
После успешной загрузки — конфиг собрался, сервис работает, но значение ведет себя странно. Скажем, в проде вместо INFO стоит DEBUG, и лог захлебывается. Хочется спросить у конфига: «Ты этот DEBUG откуда взял?» — и получить ответ «из override.yaml, строка 12» или «из переменной APP_LOG_LEVEL». Сейчас вместо этого открываешь все файлы по очереди, грепаешь, проверяешь окружение в контейнере — и каждый раз заново.
При падении на парсинге приходит ValidationError: port should be a valid integer, и нужно понять, где именно лежит проблемная строка. Текст ошибки сам по себе нормальный — но в каком из трех файлов искать port:? Если один из них смонтирован из ConfigMap — пять-десять минут руками на каждое падение в CI.
Оба сценария — про одно и то же: координаты значения должны быть частью результата загрузки. Эта информация есть прямо на момент парсинга (ruamel.yaml, tomlkit и другие AST-парсеры сохраняют (line, col) для каждого узла, но никто не тащит ее дальше. Pydantic не указывает файл вообще. Dynaconf и Hydra знают источник, но не отдают его в виде структурированного отчета. В итоге на каждой такой задаче ты заново становишься детективом, хотя данные для расследования у парсера уже на руках.
Боль: валидаторы хочется и в типе, и снаружи. Когда dataclass мой — хочется валидатор прямо в аннотации поля: port: Annotated[int, V >= 1, V <= 65535]. Добавил поле — ограничения пришли с ним, переименование ловит mypy. Pydantic-settings эту форму давно поддерживает — со стороны «своих» моделей рынок дошел до нормального состояния.
Сложнее, когда dataclass не мой: пришел из чужой библиотеки или доменной модели, которую хочется переиспользовать как конфиг. Менять класс нельзя, но валидация все еще нужна. У pydantic-settings путь — обертка или подкласс. У Dynaconf — Validator("PORT", gte=1) со строковым именем поля; работает, но переименование PORT в схеме рушит проверку молча, и узнаешь об этом, только когда невалидное значение уже доехало до прода.
Хочется обе формы из коробки и обе — без строковых ссылок на поля: для своих классов — Annotated, для чужих — отдельный параметр валидации, но через тот же типизированный путь к полю, который ловится статически.
Боль: маскировка секретов работает не везде, где должна. Защита секретов в призме закрыта частично — и только у pydantic-settings, и только по типу. У него есть SecretStr, который прячет значение в repr и логах. Этого достаточно, пока разработчик помнит пометить поле как SecretStr. Забыл — и значение полетело в лог как обычная строка.
В реальности забывают регулярно: новый сотрудник добавил поле database_password: str по аналогии с соседним database_url: str и теперь при первом же print(config) или logger.info(config) пароль уходит в лог как plain text. Автоматического срабатывания «по имени поля» или «по виду значения» нет, а хотелось бы, потому что имена секретных полей довольно предсказуемые (password, token, api_key, secret) и случайно сгенерированные токены статистически отличаются от обычных строк.
Боль: «нам нужен свой источник». В крупных компаниях редко бывает «возьмем публичный Vault и поехали». Чаще — корпоративный аналог секрет-менеджера, REST-эндпоинт «дай настройки модуля», шифрованный архив с распаковкой через внутренний KMS или просто старая SOAP-ручка, в которой живут флаги фичей. Подружить такое с pydantic-settings — это написать свой SettingsSource с переопределением _field_is_complex, _extract_field_info и пройти регресс на каждый чих в формате. Десятки строк инфраструктурного кода ради одного HTTP-вызова плюс свой пайплайн ошибок и приведения типов.
Хочется, чтобы «свой источник» был минимальным контрактом: один класс, один метод, который возвращает словарь. Все остальное — мерж с другими источниками, валидация, понятные ошибки с указанием на этот источник, отладка — должно прийти из общего пайплайна, а не быть скопировано в новый источник заново.
Боль архитектурная: библиотека, а не фреймворк. Hydra захватывает точку входа приложения и подменяет рабочую директорию. pydantic-settings навязчиво протягивает BaseModel во все методы. Dynaconf на каждый чих читает свой settings.toml где-нибудь в корне проекта.
Мне нужен инструмент, который ведет себя как функция: вызвал load(...) — получил обычный dataclass — закрыл. Никаких глобальных эффектов, никаких подмененных директорий, никакой «нашей экосистемы, в которой вам теперь жить». @dataclass из стандартной библиотеки как результат — это контракт, который вяжется с любым другим инструментом: ORM, FastAPI, тесты, сериализация. Привязки к вендору нет — пользователь инструмента не привязан к его экосистеме.
Вот сводная таблица — срез по рассмотренным инструментам.
Параметр | python-decouple | Dynaconf | pydantic-settings | Hydra |
|---|---|---|---|---|
Схема в коде | Нет | Нет | Pydantic-модель | YAML + dataclass |
Результат | Отдельные переменные | Dynaconf (dict-like) |
|
|
Форматы | .env, .ini, env vars | YAML, TOML, JSON, INI, .env, Python | .env, env vars, JSON, YAML, TOML | YAML |
Мерж источников | Нет | Слои + | Фиксированный приоритет |
|
Типобезопасность | Нет | Нет | Да (Pydantic) | Частично (OmegaConf) |
Валидация |
| Отдельные | Pydantic-валидаторы | Только типы |
Ошибки (файл/строка) | Нет | Нет | Нет | Нет |
Маскировка секретов | Нет | Нет |
| Нет |
Аудит источников | Нет |
| Нет | Output dir |
ENV expansion | Нет |
| Нет |
|
CLI | Нет | CLI утилиты |
| CLI-оверрайды |
Remote-источники | Нет | Vault, Redis | Нет | Нет |
IDE / mypy | Нет | Нет | Да | Частично |
Зависимости | Нет | Нет | pydantic (Rust core) | hydra-core, omegaconf |
Таблица — не вердикт. Если нет требования из левой колонки, соответствующая ячейка не имеет значения. Микросервису, ML-эксперименту и CLI-утилите нужны разные строчки.
В следующей статье расскажу, как из семи требований и шести болей выросла моя библиотека dature.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.