CNN TürkBitcoin ETF'lerinde 5 günlük bilanço! 510 milyon dolarlık çıkışUN NewsFormer UN rights chief awarded Nobel Peace PrizeDaily MaverickANTI-FOREIGNER UNREST: Home Affairs scraps asylum seeker directive after violence in Durban and SowetoThe Jerusalem PostParashat Bereishit: The world of waterRTP DesportoVillas-Boas diz que a venda de Rodrigo Mora evitaria prejuízo da FC Porto SADEl ComercioEcuador y los sofisticados drones Reaper: el objetivo es consolidar el control del mar, el aire y el territorioInquirerPiston protests World Bank-led transport conference in BaguioInquirer EntertainmentMiss Universe factions clash over 2027 host country claimsZDF heuteAktuelle Pressemitteilungen des ZDFColliderApple TV’s Biggest Series Officially Ends With One of Its Highest-Rated EpisodesGIGAZINEjevやGPT-6 Luna Decisionsなどの素早い判断に特化した確率モデルがパックマンをプレイするベンチマーク「jevman」VarietyGromit Speaks: Martin Freeman to Voice Beloved but Mute Dog in Audiobook Edition of Autobiography
The Daily Newsstand · Free, Always
Friday, October 9, 2026

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

Translate

Привет, Хабр! Я Николай Видов, тимлид команды чатботов в Т, пишу на 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)

BaseSettings (Pydantic)

DictConfig (OmegaConf)

Ваш @dataclass

Форматы

.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-валидаторы

Только типы

Annotated и/или отдельные валидаторы + root-валидаторы

Ошибки с координатами

Нет

Нет

Нет

Нет

Да

Маскировка секретов

Нет

Нет

SecretStr

Нет

Да (тип, имя, эвристика)

Аудит источников

Нет

inspect_settings

Нет

Output dir

debug=True (источник поля)

ENV expansion

Нет

@format, @jinja

Нет

${oc.env:VAR}

${VAR:-default}

CLI

Нет

CLI-утилиты

CliSettingsSource

CLI-оверрайды

ArgparseSource, CliSource

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?». 

Если узнаете сценарий, приглашаю пробовать. Часть болей закрыта пока наполовину, если упретесь во что-то новое, чего там нет, — напишите задачу. Из таких задач я и понимаю, что делать дальше.

Полезные ссылки:

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.