
Привет, Хабр! Я Николай Видов, тимлид команды чатботов в Т, пишу на Python с 2017 года. За это время я успел поработать с конфигами во всех видах: INI‑файлами в legacy‑проектах, переменными окружения в Docker‑контейнерах, YAML с шаблонами в Helm, pydantic‑моделями в свежих сервисах, Vault и Consul в инфраструктуре покрупнее. Это третья статья о конфигурациях в Python — в предыдущих я разобрал, как мы дошли до жизни такой и почему это не устраивает меня и мою команду.
От болей к техзаданию
Шесть болей стали техническим заданием:
мердж отдельных полей из разных источников;
непонятно откуда пришло невалидное значение;
валидаторы хочется иметь и внутри, и снаружи;
маскировка секретов работает не везде;
нужна поддержка добавления своего источника;
это должна быть библиотека, а не фреймворк.
Из этих болей выросла моя библиотека dature. Если критично интегрироваться с FastAPI dependency injection, pydantic‑settings дает более прямой путь. У dature своя ниша, и она не вытесняет все остальное, а закрывает конкретные боли мои и моей команды.

Философия dature
dature — это библиотека. Ее единственная задача — загрузить данные из произвольных источников в @dataclass из стандартной библиотеки и вернуть готовый объект. Она не захватывает точку входа, не меняет рабочую директорию, не управляет логированием.
Dataclass — единственный источник правды. Хинты описывают и структуру, и типы, и ограничения. Не нужно дублировать определения в YAML‑файлах, отдельных валидаторах или Pydantic‑моделях.
from dataclasses import dataclass import dature @dataclass class Config: host: str port: int debug: bool = False config = dature.load(dature.Yaml12Source(file="config.yaml"), schema=Config) # isinstance(config, Config) → True # Полная поддержка IDE, mypy, __post_init__
Загрузка из любого источника с сохранением происхождения
Главная идея dature — не терять происхождение значения после парсинга. Для каждого поля известно, из какого источника и из какой строки оно пришло, и это доступно и в ошибках валидации, и в debug‑отчете.
Список поддерживаемых источников при этом ожидаемо широкий:
восемь файловых форматов с автодетекцией по расширению: YAML 1.1/1.2, JSON, JSON5, TOML 1.0/1.1, INI,.env;
переменные окружения;
Docker secrets;
Valut, Consul, etcd, ZooKeeper, AWS SSM, AWS Secrets Manager, Azure App Configuration, Azure Key Vault, GCP Secret Manager;
argparse‑CLI;
собственный CLI‑источник.
Один вызов — несколько источников:
import dature @dataclass class Config: host: str port: int secret_key: str debug: bool = False config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Toml11Source(file="config.toml", skip_if_missing=True), dature.EnvFileSource(file=".env", skip_if_broken=True), dature.EnvSource(prefix="APP_"), # переменные окружения с префиксом APP_ schema=Config, )
skip_if_broken=True означает: если файл невалиден, источник молча пропускается. skip_if_missing=True означает: если файл отсутствует, источник молча пропускается Это позволяет описать цепочку «значения по умолчанию → локальные переопределения → переменные среды» без проверок существования файлов руками.
Формат определяется автоматически по расширению. Если нужен нестандартный загрузчик, пишется свой Source.
Мерж‑стратегии
Четыре глобальные стратегии определяют, как разрешать конфликты между источниками:
last_wins— последний источник перекрывает предыдущие (по умолчанию);first_wins— первый источник имеет приоритет;first_found— берется первый не битый источник, остальные игнорируются;raise_on_conflict— ошибка, если два источника задают разные значения.
Для отдельных полей можно задать свои правила. Например, список серверов из разных источников хочется объединять, а не заменять:
import dature @dataclass class Config: servers: list[str] port: int config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Yaml12Source(file="local.yaml", skip_if_broken=True), dature.EnvSource(prefix="APP_"), field_merges={dature.F[Config].servers: "append_unique"}, schema=Config, )
Группы полей (field groups) позволяют контролировать согласованность: если одно поле из группы переопределено в новом источнике, остальные тоже должны быть переопределены:
import dature dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Yaml12Source(file="override.yaml"), field_groups=( (dature.F[Config].db_host, dature.F[Config].db_port, dature.F[Config].db_name), ), schema=Config, ) # Если override.yaml меняет db_host, но не db_port → ошибка
Валидация
dature использует Annotated для встроенных валидаторов:
from dataclasses import dataclass from typing import Annotated import dature @dataclass class Config: port: Annotated[int, (dature.V >= 1) & (dature.V <= 65535)] host: Annotated[str, dature.V.len() >= 1] log_level: Annotated[str, dature.V.matches(r"^(DEBUG|INFO|WARNING|ERROR)$")]
Валидатор живет прямо в типе поля, и это важно. Когда добавляешь новое поле в dataclass, ты физически не можешь забыть прописать ограничения: они часть сигнатуры. При этом валидаторы можно задавать и отдельно — через Source, когда нужно переиспользовать одну схему с разными правилами или вынести проверки в другой модуль:
import dature config = dature.load( dature.Yaml12Source( file="config.yaml", validators={ dature.F[Config].port: (dature.V >= 1) & (dature.V <= 65535), }, ), schema=Config, )
Для проверок между полями есть root‑валидаторы:
import dature def check_ssl(config: Config) -> bool: return not config.use_ssl or config.cert_path is not None config = dature.load( dature.Yaml12Source( file="config.yaml", root_validators=( dature.V.root(func=check_ssl, error_message="cert_path required when use_ssl is True"), ), ), schema=Config, )
Стандартный __post_init__ тоже работает — dature возвращает обычный dataclass. Когда валидация не проходит, ошибка указывает на конкретное место:
Config loading errors (1) [port] Must be greater than 0 ├── port: -1 │ ^^ └── FILE 'config.yaml', line 2
В сообщении есть все, чтобы починить за минуту: путь к файлу, номер строки, само значение и стрелочка под проблемным куском. Это та самая разница, которая в логах на проде стоит часа дебага.
Секреты и отладка
Поля типа SecretStr автоматически маскируются в ошибках и при выводе:
from dataclasses import dataclass import dature from dature.fields import SecretStr @dataclass class Config: database_url: str api_key: SecretStr config = dature.load(dature.Yaml12Source(file="config.yaml"), schema=Config) print(config.api_key) # SecretStr('<REDACTED>') print(config.api_key.get_secret_value()) # реальное значение
Маскировка работает на трех уровнях:
По типу —
SecretStr,PaymentCardNumber.По имени поля. По умолчанию маскируются поля с подстроками password, passwd, secret, token, api_key, apikey, api_secret, access_key, private_key, auth, credential. Список настраивается через
MaskingConfig.secret_field_names.
По эвристике — анализ биграмм. Строка переводится в нижний регистр, разбивается на биграммы, и доля редких биграмм сравнивается с порогом (
heuristic_threshold, по умолчанию 0,5). Если редких больше половины, строка считается случайно сгенерированным токеном и маскируется. Минимальная длина строки для анализа — 8 символов (min_heuristic_length). Эвристика требует опциональной зависимости random_string_detector — без нее работают только первые два уровня.
Режим отладки показывает, откуда пришло каждое значение:
import dature config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.EnvSource(prefix="APP_"), schema=Config, debug=True, ) report = dature.get_load_report(config) for origin in report.field_origins: print(f"{origin.key} = {origin.value} ← {origin.source_file or origin.source_loader_type}") # host = 'localhost' ← defaults.yaml # port = 9090 ← env # debug = False ← defaults.yaml
Режим отладки становится особенно полезным, когда конфиг собирается из нескольких источников и непонятно, какой из них доминирует в данном поле: debug=True показывает путь каждого значения от источника до финального объекта.
Кастомные поля и источники
dature умеет работать с собственными типами и форматами. Регистрация loader для нового типа:
from dataclasses import dataclass import dature @dataclass(frozen=True, slots=True) class Rgb: r: int g: int b: int def rgb_from_string(value: str) -> Rgb: parts = value.split(",") return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2])) @dataclass class AppConfig: name: str color: Rgb config = dature.load( dature.Yaml12Source( file=SOURCES_DIR / "custom_type_common.yaml", type_loaders={Rgb: rgb_from_string}, # applies only to this file source ), schema=AppConfig, )
Свой формат — это подкласс от FileSource:
import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path import dature from adaptix import Provider, loader from dature.loaders import bool_loader, float_from_string from dature.sources.base import FileSource from dature.types import FileOrStream, JSONValue @dataclass(kw_only=True, repr=False) class XmlSource(FileSource): format_name = "xml" def _load_file(self, path: FileOrStream) -> JSONValue: if not isinstance(path, Path): msg = "XmlSource only supports file paths" raise TypeError(msg) tree = ET.parse(path) # noqa: S314 root = tree.getroot() return {child.tag: child.text or "" for child in root} def format_loaders(self) -> list[Provider]: return [ loader(bool, bool_loader), loader(float, float_from_string), ] @dataclass class Config: host: str port: int debug: bool config = dature.load( XmlSource(file="custom_loader.xml"), schema=Config, )
Сценарии из практики, где dature помогает
Чтобы фичи не висели в воздухе, разберу несколько типичных сценариев. В этих ситуациях обычно приходится писать много шаблонного кода, а с dature мы укладываемся в несколько строк.
Сценарий: списки сливать, остальное заменять. Конфиг приложения: общие для всех значения по умолчанию для CORS‑origins, для каждого окружения — свои дополнительные. Поведение, которого хочется: списки объединять, поле database.host заменять по приоритету источников.
Раньше приходилось писать кастомный SettingsSource в pydantic‑settings, переопределять _field_is_complex, проверять, что для конкретного поля включаем deep‑merge. Получалось двадцать строк инфраструктурного кода ради одного бизнес‑сценария.
С dature это одна строка field_merges:
config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Yaml12Source(file=f"config.{env}.yaml", skip_if_broken=True), dature.EnvSource(prefix="APP_"), field_merges={ dature.F[Config].cors_origins: "append_unique", }, schema=Config, )
Все остальное: last_wins по умолчанию, кастомный source‑класс не нужен.
Сценарий: откуда пришло каждое поле, отвечает сам конфиг. Классический детектив на проде. Деплоймент жалуется, что port в продакшене получился 9090, а в defaults.yaml стоит 8080. Нужно быстро понять источник переопределения: override.yaml, переменные среды или Kubernetes ConfigMap. Поэтому конфиг должен не только хранить итоговое значение, но и показывать его происхождение.
Без подходящего инструмента детектив начинается с похода в контейнер: env | grep APP_, проверить смонтированные файлы, перечитать docker‑compose. На то, чтобы понять происхождение одного‑единственного значения, порой уходит половина рабочего дня.
С dature:
config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Yaml12Source(file="override.yaml", skip_if_broken=True), dature.EnvSource(prefix="APP_"), schema=Config, debug=True, ) report = dature.get_load_report(config) for origin in report.field_origins: print(f"{origin.key} = {origin.value} ← {origin.source_file or origin.source_loader_type}")
В выводе сразу видно: port = 9090 ← env. То есть значение пришло из переменной среды — детектив закрыт за минуту, а не за полдня перебора файлов в контейнере. Особенно полезно в CI: можно прогонять load(..., debug=True) как часть тестового этапа и валидировать, откуда какое поле должно приходить в прод.
Сценарий: свой источник для внутреннего сервиса конфигурации. В крупной компании есть свой сервис конфигурации — REST‑эндпоинт, который отдает настройки для запрашивающего модуля. Менять его на «обычный Vault» никто не будет, и до dature остаются варианты: грузить вручную и склеивать с остальными источниками руками либо писать кастомный SettingsSource в pydantic‑settings со всеми вытекающими.
С dature свой источник — это subclass от Source с одним методом _load:
import requests import dature from dature.sources.base import Source from dature.types import JSONValue @dataclass(kw_only=True, repr=False) class InternalConfigApiSource(Source): service: str def _load(self) -> JSONValue: response = requests.get( f"https://config.internal/api/v1/{self.service}", timeout=5, ) response.raise_for_status() return response.json() config = dature.load( dature.Yaml12Source(file="defaults.yaml"), InternalConfigApiSource(service="payments"), dature.EnvSource(prefix="APP_"), schema=Config, )
Никакого собственного пайплайна ошибок, мерж‑стратегий, маскировки секретов. Свой источник встал в общую цепочку и автоматически получил все остальное.
Сценарий: согласованные изменения связанных полей. Команда переезжает в staging‑окружение, у БД меняются все параметры разом: db_host, db_port, db_name, db_user. Хочется гарантии: либо staging.yaml переопределяет все четыре, либо загрузка падает с понятной ошибкой. Никаких пол‑переезда.
field_groups ровно это и делает:
config = dature.load( dature.Yaml12Source(file="defaults.yaml"), dature.Yaml12Source(file="staging.yaml", skip_if_broken=True), field_groups=( ( dature.F[Config].db_host, dature.F[Config].db_port, dature.F[Config].db_name, dature.F[Config].db_user, ), ), schema=Config, ) # Если staging.yaml меняет db_host, но не db_port → ошибка с указанием # группы и того, какое поле осталось из defaults.yaml
Ошибка ловится в момент загрузки, а не в момент первого подключения к чужой БД.
Сценарий: динамическая конфигурация без рестарта. Долгоживущий сервис, в YAML лежат лимиты запросов, и менять их хочется на лету, без рестарта. Перечитывать на каждый запрос — дорого, перечитывать руками — ненадежно.
cache=timedelta(...) решает это в одной строке: каждый класс, попавший в общий Loader, инвалидируется в один и тот же момент и следующий вызов получает свежие значения:
loader = dature.Loader( dature.Yaml12Source(file="config.yaml"), schema=Config, cache=timedelta(seconds=30), ) # В любой точке кода config = loader.load() # за один бакет — один реальный re-parse
Между «всегда из коробки свежее» и «один раз при старте» появилось среднее звено, и стоимость подхода — один параметр.
Сценарий: путь к конфигу приходит снаружи. Контейнер не знает, где лежит его конфиг: путь монтируется во время релиза и пробрасывается через переменную окружения. Иногда туда же добавляется токен для Vault или имя активного профиля. Получается двухступенчатая загрузка: сначала вычитать ENV, потом из этих значений собрать аргументы для основных источников.
Обычно это пишется руками: os.environ.get, проверка на отсутствие, конкатенация пути и только потом вызов загрузчика. Если источников больше двух, между ними появляется императивный клей.
С dature источники ссылаются друг на друга через ${@tag.key} прямо в аргументах:
config = dature.load( dature.JsonSource(file="${@env.config_path}"), dature.VaultSource( path="secret/app", token="${@env.VAULT_TOKEN}", ), dature.EnvSource(prefix="APP_"), schema=Config, )
Порядок источников в load() неважен: dature сам построит граф зависимостей и загрузит их в правильном порядке. Циклы, отсутствующие теги и неразрешимые ссылки ловятся до первого I/O.
Сценарий: один и тот же load() в dev и prod. В проде секреты приходят из Vault, в dev — из локального.env‑файла, к которому Vault даже не подключен. Хочется, чтобы код загрузки конфига был одинаковым в обоих окружениях, а не обмазывался if ENV == “prod”.
Без подходящего инструмента получаются либо две разные функции загрузки, либо try/except вокруг VaultSource в надежде, что в dev он просто упадет. Второе особенно неприятно: источник все равно пытается ходить в сеть и ловить таймаут, прежде чем сдаться.
С dature каждый источник получает when= — отключенный не открывается вообще:
config = dature.load( dature.VaultSource( tag="secrets", path="secret/app", token="${VAULT_TOKEN}", when=dature.When("${APP_ENV}") == "prod", ), dature.EnvFileSource( tag="secrets", file="vault_dev.env", when=dature.When("${APP_ENV}").in_("dev", "local"), ), schema=Config, )
Оба источника живут под одним tag="secrets", условия взаимоисключающие — активен только один. В prod VaultSource ходит в Vault, в dev он даже не пытается, никаких сетевых таймаутов на старте. Если оба условия случайно перекрываются, dature падает на этапе конструирования — до I/O.
Архитектурные решения «почему так»
Несколько решений в dature неочевидны на первый взгляд и стоят пояснения. Если хочется использовать библиотеку всерьез, стоит понимать, почему сделано именно так.
Почему @dataclass из стандартной библиотеки, а не свой класс. Pydantic выбрал BaseModel. attrs выбрал свой AttrsInstance. У обоих своя экосистема, свои соглашения, своя вирулентность — когда базовый класс начинает протекать в код пользователя и от него потом не отвязаться без рефакторинга. У BaseModel, например, есть model_validate, model_dump — целая мета‑инфраструктура. Она удобна, пока вы внутри экосистемы Pydantic, но мешает при попытке использовать вашу схему в чужом контексте.
Stdlib @dataclass — общий знаменатель Python с 3.7. С ним работают все: mypy, IDE, ORMs (SQLAlchemy 2.0 поддерживает dataclass‑стиль декларации моделей), FastAPI (можно использовать как request/response‑модели), сериализационные библиотеки, тестовые фреймворки. Возвращая dataclass, dature не заставляет пользователя приносить в проект новую парадигму моделирования данных.
Цена этого выбора: dataclass беднее по фичам. Нет Field(alias=...), нет валидаторов из коробки, нет model_dump. dature закрывает эту нехватку поверх: Annotated‑валидаторы заменяют декораторы методов класса, маппинг ключей источника на поля делается в Source (не в самой схеме).
Почему именно adaptix, а не cattrs/msgspec. Конвертация типов — отдельная сложная задача. Когда YAML отдает "true" и в dataclass‑поле bool, кто‑то должен это привести. Когда "2024-01-15" идет в поле datetime — то же самое. Когда вложенный словарь идет в сложенный dataclass, это уже рекурсия с обработкой опциональных полей и значений по умолчанию.
Можно написать свое, но это значит переписывать функциональность, которую уже делают adaptix, cattrs и msgspec. Cattrs тоже умеет dataclasses, но adaptix лучше лег на задачу dature из‑за модели retort + provider‑recipe. Каждый провайдер точечно переопределяет один аспект загрузки, и настройку можно повесить не на класс целиком, а на конкретные поля и типы.
msgspec для полноценной валидации требует наследоваться от msgspec.Struct, что противоречит идее dature “dataclass из stdlib как единственный источник правды”. А dec_hook в msgspec заметно беднее provider‑модели, когда нужны разные правила для одного и того же типа в разных полях. Дополнительный бонус adaptix — отсутствие автокастинга по умолчанию: loader не пытается угадать значение из множества входных форматов, и для конфигов это правильное поведение (явная ошибка лучше, чем тихо приведенный "yes" → True).
Одна обязательная зависимость — это компромисс. dature не без зависимостей, как python‑decouple. Но это та зависимость, которая делает свою работу и не тянет за собой Pydantic‑core (Rust‑байт‑код).
Почему Annotated‑валидаторы как основной путь. Annotated‑стиль сегодня уже не уникален: pydantic‑settings давно поддерживает Annotated[int, Ge(1), Le(65535)] рядом с привычным @field_validator. Но в dature это основной путь для валидации, привязанной к схеме. Валидатор живет в самой аннотации поля:
@dataclass class Config: port: Annotated[int, (V >= 1) & (V <= 65535)]
Правило остается рядом с полем, но не запирается внутри конкретного класса. Из этого вытекают два следствия. Первое: типизированный пресет Port = Annotated[int, (V >= 1) & (V <= 65535)] можно положить в общий модуль и импортировать в любую схему. Второе: V‑DSL дает композицию через &, |, ~ и шорткаты под коллекции (V.each(...), V.unique_items()), так что сложные предикаты собираются без декораторов поверх класса.
А если dataclass пришел из чужого пакета и трогать его нельзя, тогда валидаторы передаются параметром validators={F[Config].port: ...} в Source — через тот же типизированный путь к полю, без перехода на строки. Это закрывает сценарий с чужими dataclass: валидацию можно добавить снаружи, не меняя класс, не создавая обертку и не ссылаясь на поля строками.
Минус подхода: у тех, кто давно сидит на @field_validator, первое время будет искать привычный метод класса.
Почему хорошие сообщения об ошибках — главный приоритет формата. Большинство либ возвращают ValidationError: port should be valid integer без указания, в каком источнике произошел сбой. dature специально тащит координаты узла (line, col) через весь пайплайн — от парсера YAML/TOML/JSON до точки, где формируется итоговое сообщение об ошибке.
Сохранить координаты конкретного значения от парсера до финального сообщения об ошибке технически нетривиально. Стандартные парсеры (yaml.safe_load, json.load) координаты не сохраняют — нужно использовать AST‑режим (ruamel.yaml, tomlkit), а его API не такой удобный, как у safe_load. Затем эти координаты нужно пронести через все этапы преобразования: из словаря в dataclass, через валидацию, в финальное сообщение об ошибке.
Цена реализации высокая, поэтому почти никто этого не делает. И именно здесь у dature появляется конкурентное преимущество — не в очередной мерж‑стратегии и не в новом формате, а в UX ошибок. Сэкономленное на проде время дебага — та ценность, ради которой стоит попробовать новую библиотеку конфигурации.
Сравнительная таблица возможностей.
Параметр |
python‑decouple |
Dynaconf |
pydantic‑settings |
Hydra |
dature |
|---|---|---|---|---|---|
Схема в коде |
Нет |
Нет |
Pydantic‑модель |
YAML + dataclass |
stdlib dataclass |
Результат |
Отдельные переменные |
Dynaconf (dict‑like) |
|
|
Ваш |
Форматы |
env,.ini, env vars |
YAML, TOML, JSON, INI,.env, Python |
env, env vars, JSON, YAML, TOML |
YAML |
YAML, JSON, JSON5, TOML, INI,.env, env vars, Docker secrets. |
Мерж источников |
Нет |
Слои + dynaconf_merge |
Фиксированный приоритет |
Defaults list |
4 встроенные стратегии + возможность сделать свою + per‑field правила |
Типобезопасность |
Нет |
Нет |
Да (Pydantic) |
Частично (OmegaConf) |
Да (dataclass + adaptix) |
Валидация |
cast |
Отдельные Validator |
Pydantic‑валидаторы |
Только типы |
|
Ошибки с координатами |
Нет |
Нет |
Нет |
Нет |
Да |
Маскировка секретов |
Нет |
Нет |
|
Нет |
Да (тип, имя, эвристика) |
Аудит источников |
Нет |
|
Нет |
Output dir |
|
ENV expansion |
Нет |
|
Нет |
|
|
CLI |
Нет |
CLI‑утилиты |
|
CLI‑оверрайды |
|
Remote‑источники |
Нет |
Vault, Redis |
Нет |
Нет |
Vault, GCP, Azure и так далее |
IDE/mypy |
Нет |
Нет |
Да |
Частично |
Да |
Зависимости |
Нет |
Нет |
pydantic (Rust core) |
hydra‑core, omegaconf |
adaptix |
Если у вас нет требования из левой колонки, соответствующая ячейка не имеет значения. Микросервису, ML‑эксперименту и CLI‑утилите нужны разные строчки.
Что может удивить при переезде с pydantic‑settings
Чаще всего на dature смотрят те, кто уперся в потолок pydantic‑settings. Вот краткий список мест, на которых можно споткнуться при переезде.
Загрузочная логика живет в load(...), а не в схеме. Префикс ENV, путь до.env, цепочка источников — все это параметры Source‑объектов и аргументы load(...), а не model_config в классе.
Валидаторы — через Annotated и validators={...}. Декораторов методов уровня @field_validator/@model_validator нет. Проверки, для которых нужно несколько полей, передаются параметром root_validators=(...) в Source.
Coercion строже. Adaptix не приводит "true" → True молча. На YAML/TOML это незаметно, на ENV — может потребовать явных лоадеров из dature.loaders.
Optional[X] не делает поле опциональным автоматически — это особенность stdlib dataclass, нужно писать = None руками.
SecretStr — свой класс, не pydantic.SecretStr. API совпадает, но это два разных типа.
Для model_dump() нужен свой helper. dature возвращает обычный dataclass. dataclasses.asdict() про SecretStr не знает и сольет секрет в дамп. Для логирования безопасен repr(config).
Большинство пунктов — разные дизайн‑выборы, а не «у dature чего‑то не хватает». Если они не ложатся в какой‑то стиль мышления, это нормальный сигнал, что pydantic‑settings подходит лучше.

Заключение
Расклад остается тем же, что в прошлой статье: для скриптов хватит python‑decouple, для динамики без схемы — Dynaconf, под Pydantic в проекте — pydantic‑settings, для ML‑свипов — Hydra, пока она жива.
dature нужна там, где из всего этого ничего ровно не подходит. Чаще всего это долгоживущий сервис, в котором конфиг собирается из нескольких источников и однажды ты тратишь полдня на вопрос «Откуда вообще пришел этот port=9090?».
Если узнаете сценарий, приглашаю пробовать. Часть болей закрыта пока наполовину, если упретесь во что‑то новое, чего там нет, — напишите задачу. Из таких задач я и понимаю, что делать дальше.
Полезные ссылки:
Рассмотренные библиотеки:
Библиотеки сериализации