Skip to content

Loading#

How Single-Source Loading Works#

graph TD
    S["Source"] --> RAW["Read source data"]
    RAW --> SK{"skip_invalid_fields?"}
    SK -- yes --> DROP["Drop fields that fail\ntype or constraint check"]
    SK -- no --> FV{"Source has field validators?"}
    DROP --> ROOT
    FV -- yes --> FP["Validate fields provided\nby this source"]
    FV -- no --> ROOT
    FP --> ROOT["Construct dataclass\n+ run all validators\n(root_validators + Annotated on defaults)"]
    ROOT --> D["Dataclass"]

dature reads your config from a single source, converts every raw value into the type its dataclass field declares, checks it against the rules you attached, and returns a ready, fully-validated instance. Validation happens in distinct tiers; if anything fails, dature gathers every error into one report naming the field, its location in the source, and why it failed.

  1. Read the source. Raw values are pulled from the source. With expand_env_vars, ${VAR} placeholders are substituted first; secrets are masked in error/debug output per mask_secrets / secret_field_names.
  2. Optionally drop invalid values (skip_invalid_fields). When enabled, each provided value is probed against its field's type and rules, and anything that would fail is quietly dropped so the field can fall back to its dataclass default instead of failing the whole load. The field-validator pass (step 3) is then skipped — remaining fields are counted as already checked. Without the flag, nothing is dropped and failures surface as errors.
  3. Field-level validation. Every value the source provided is coerced to the field's type and checked against that field's declared rules: declared type, Annotated constraints (ranges, patterns, custom predicates), plus any per-source validators= attached to the source. Fields the source did not provide are left untouched here.
  4. Construct and validate dataclass. Once the values are assembled into the instance, root_validators= run — checks that span several fields at once (e.g. "start must be before end"). Fields no source supplied take their dataclass default; any Annotated constraints on those defaults are still enforced.
  5. Result. A fully typed, fully validated config instance — or a single grouped error report of every field that failed across all tiers at once.

For multi-source loading, see Merging.


When a dature.load(...) call fails, the error message tells you which field broke, where in the source it came from, and why. This page walks through the failures you are most likely to hit while wiring up your first config — and one pattern for recovering from them.

All examples share the same schema

Source does not exist#

Wrong path or wrong working directory — the most common first error. dature raises a plain FileNotFoundError before any parsing happens.

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "does_not_exist.yaml"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | FileNotFoundError: Config file not found: {SOURCES_DIR}does_not_exist.yaml
    +------------------------------------

Source exists but is broken#

The file is present but the parser can't read it (here: invalid YAML indentation). dature does not swallow parser errors — the underlying exception propagates with the original file and line.

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "broken.yaml"),
    schema=Config,
)
host: localhost
port: 8080
  bad_indent: oops
debug: false
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | ruamel.yaml.scanner.ScannerError: mapping values are not allowed here
    |   in "{SOURCES_DIR}broken.yaml", line 3, column 13
    +------------------------------------

Type mismatch#

The source parses, but a value can't be coerced to the field's annotated type. dature raises a FieldLoadError with the field path, the offending value, a caret pointing at it, and the source location.

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "type_mismatch.yaml"),
    schema=Config,
)
host: localhost
port: not_a_number
debug: false
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  invalid literal for int() with base 10: 'not_a_number'
    |    ├── port: not_a_number
    |    │         ^^^^^^^^^^^^
    |    └── FILE '{SOURCES_DIR}type_mismatch.yaml', line 2
    +------------------------------------

Required field missing#

A field with no default value is absent from the source. The error points at the file but has no line — there is nothing in the source to highlight.

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "missing_field.yaml"),
    schema=Config,
)
host: localhost
debug: false
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Missing required field
    |    └── FILE '{SOURCES_DIR}missing_field.yaml'
    +------------------------------------

Multiple errors at once#

dature does not stop at the first error — it keeps going and reports every failed field together as an ExceptionGroup. You fix the config in one pass instead of "fix, rerun, fix, rerun".

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "multiple_errors.yaml"),
    schema=Config,
)
host: localhost
port: not_a_number
debug: maybe
  | dature.errors.exceptions.DatureConfigError: Config loading errors (2)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  invalid literal for int() with base 10: 'not_a_number'
    |    ├── port: not_a_number
    |    │         ^^^^^^^^^^^^
    |    └── FILE '{SOURCES_DIR}multiple_errors.yaml', line 2
    +---------------- 2 ----------------
    | dature.errors.exceptions.FieldLoadError:   [debug]  Expected bool, got str
    |    ├── debug: maybe
    |    │          ^^^^^
    |    └── FILE '{SOURCES_DIR}multiple_errors.yaml', line 3
    +------------------------------------

Recovering: skip an unavailable source#

When merging multiple sources, an absent or malformed one can be skipped so the next source supplies the values. Use skip_if_missing=True for files that may not exist, or skip_if_broken=True for files that exist but may be malformed:

from dataclasses import dataclass

import dature


@dataclass
class Config:
    host: str
    port: int
    debug: bool = False


config = dature.load(
    dature.Yaml12Source(
        file=SOURCES_DIR / "does_not_exist.yaml",
        skip_if_missing=True,
    ),
    dature.Yaml12Source(file=SOURCES_DIR / "fallback.yaml"),
    schema=Config,
)

assert config.host == "localhost"
assert config.port == 8080
assert config.debug is False
host: localhost
port: 8080
debug: false

If every source fails, dature still raises — there is no value to load. See Skipping Sources with Parse Errors and Skipping Missing Sources for the full picture, including per-source overrides and skip_invalid_fields.