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

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

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

Рассмотренные библиотеки:

Библиотеки сериализации

Комментарии (0)