dature: type‑safe‑конфигурация через dataclasses


Привет, Хабр! Я Николай Видов, тимлид команды чатботов в Т, пишу на 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?».
Если узнаете сценарий, приглашаю пробовать. Часть болей закрыта пока наполовину, если упретесь во что-то новое, чего там нет, — напишите задачу. Из таких задач я и понимаю, что делать дальше.
Полезные ссылки:
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.