Skip to content

Validation#

dature supports multiple validation approaches: Annotated type hints, root validators, metadata validators, custom validators, and standard __post_init__.

Annotated Validators#

Declare validators using typing.Annotated:

"""Annotated validators — error example."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class ServiceConfig:
    port: Annotated[int, (V >= 1) & (V <= 65535)]
    name: Annotated[str, (V.len() >= 3) & (V.len() <= 50)]
    tags: Annotated[list[str], (V.len() >= 1) & V.unique_items()]
    workers: Annotated[int, V >= 1]


dature.load(
    dature.Json5Source(file=SOURCES_DIR / "validation_annotated_invalid.json5"),
    schema=ServiceConfig,
)
{
  // Invalid: port=0 violates Ge(1), name too short for MinLength(3), duplicate tags
  port: 0,
  name: "ab",
  tags: ["web", "web"],
  workers: 0,
}
  | dature.errors.exceptions.DatureConfigError: ServiceConfig loading errors (4)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port: 0,
    |    │         ^
    |    └── FILE '{SOURCES_DIR}validation_annotated_invalid.json5', line 3
    +---------------- 2 ----------------
    | dature.errors.exceptions.FieldLoadError:   [name]  Value length must be greater than or equal to 3
    |    ├── name: "ab",
    |    │          ^^
    |    └── FILE '{SOURCES_DIR}validation_annotated_invalid.json5', line 4
    +---------------- 3 ----------------
    | dature.errors.exceptions.FieldLoadError:   [tags]  Value must contain unique items
    |    ├── tags: ["web", "web"],
    |    │         ^^^^^^^^^^^^^^
    |    └── FILE '{SOURCES_DIR}validation_annotated_invalid.json5', line 5
    +---------------- 4 ----------------
    | dature.errors.exceptions.FieldLoadError:   [workers]  Value must be greater than or equal to 1
    |    ├── workers: 0,
    |    │            ^
    |    └── FILE '{SOURCES_DIR}validation_annotated_invalid.json5', line 6
    +------------------------------------

Available Validators#

Numbers (dature.validators.number):

Validator Description
Gt(N) Greater than N
Ge(N) Greater than or equal to N
Lt(N) Less than N
Le(N) Less than or equal to N

Strings (dature.validators.string):

Validator Description
MinLength(N) Minimum string length
MaxLength(N) Maximum string length
RegexPattern(r"...") Match regex pattern

Sequences (dature.validators.sequence):

Validator Description
MinItems(N) Minimum number of items
MaxItems(N) Maximum number of items
UniqueItems() All items must be unique

Multiple validators can be combined:

    port: Annotated[int, (V >= 1) & (V <= 65535)]
    tags: Annotated[
        list[str],
        (V.len() >= 1) & (V.len() <= 10) & V.unique_items(),
    ]

Root Validators#

Validate the entire object after loading:

"""Root validator — error example."""

from dataclasses import dataclass
from pathlib import Path

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


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


def check_debug_not_on_production(obj: Config) -> bool:
    if obj.host != "localhost" and obj.debug:
        return False
    return True


dature.load(
    dature.Yaml12Source(
        file=SOURCES_DIR / "validation_root_invalid.yaml",
        root_validators=(
            V.root(
                check_debug_not_on_production,
                error_message=(
                    "debug=True is not allowed on non-localhost hosts"
                ),
            ),
        ),
    ),
    schema=Config,
)
host: production.example.com
port: 8080
debug: true
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [<root>]  debug=True is not allowed on non-localhost hosts
    |    └── FILE '{SOURCES_DIR}validation_root_invalid.yaml'
    +------------------------------------

Root validators receive the fully constructed dataclass instance and return True if valid.

Metadata Validators#

Field validators can be specified in Source using the validators parameter. Useful when the same dataclass is loaded from different sources with different validation rules. These validators complement (not replace) any Annotated validators:

"""Metadata validators — error example."""

from dataclasses import dataclass
from pathlib import Path

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


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


dature.load(
    dature.Yaml12Source(
        file=SOURCES_DIR / "validation_metadata_invalid.yaml",
        validators={
            dature.F[Config].host: V.len() >= 1,
            dature.F[Config].port: (V >= 1) & (V < 65536),
        },
    ),
    schema=Config,
)
host: ""
port: 0
  | dature.errors.exceptions.DatureConfigError: Config loading errors (2)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [host]  Value length must be greater than or equal to 1
    |    ├── host: ""
    |    │         ^^
    |    └── FILE '{SOURCES_DIR}validation_metadata_invalid.yaml', line 1
    +---------------- 2 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port: 0
    |    │         ^
    |    └── FILE '{SOURCES_DIR}validation_metadata_invalid.yaml', line 2
    +------------------------------------

A single validator can be passed directly. Multiple validators require a tuple:

validators = {
    dature.F[Config].port: (V > 0) & (V < 65536),  # composed with & (preferred)
    dature.F[Config].host: V.len() >= 1,  # single predicate
}

# alternative: tuple of predicates — equivalent to &
validators_tuple = {
    dature.F[Config].port: (V > 0, V < 65536),
}

Nested fields are supported:

validators = {
    dature.F[Config].database.host: V.len() >= 1,
    dature.F[Config].database.port: V > 0,
}

Custom Validators#

Create your own validators by implementing get_validator_func() and get_error_message(). The validator must be a frozen dataclass:

"""Custom validator — error example using V.check as escape hatch."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class ServiceConfig:
    port: int
    name: str
    tags: list[str]
    workers: Annotated[
        int,
        (V >= 1)
        & V.check(
            lambda v: v % 2 == 0,
            error_message="Value must be divisible by 2",
        ),
    ]


dature.load(
    dature.Json5Source(file=SOURCES_DIR / "validation_custom_invalid.json5"),
    schema=ServiceConfig,
)
{
  port: 8080,
  name: "my-service",
  tags: ["web", "api"],
  workers: 3,
}
  | dature.errors.exceptions.DatureConfigError: ServiceConfig loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [workers]  Value must be divisible by 2
    |    ├── workers: 3,
    |    │            ^
    |    └── FILE '{SOURCES_DIR}validation_custom_invalid.json5', line 5
    +------------------------------------

Custom validators can be combined with built-in ones in Annotated.

__post_init__ and @property#

Standard dataclass __post_init__ and @property work as expected — dature preserves them during loading:

"""__post_init__ validation — error example."""

from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


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

    def __post_init__(self) -> None:
        if self.port < 1 or self.port > 65535:
            msg = f"port must be between 1 and 65535, got {self.port}"
            raise ValueError(msg)

    @property
    def address(self) -> str:
        return f"{self.host}:{self.port}"


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "validation_post_init_invalid.yaml"),
    schema=Config,
)
host: localhost
port: 99999
debug: false
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | ValueError: port must be between 1 and 65535, got 99999
    +------------------------------------

Both approaches work in function mode and decorator mode.

Error Format#

Validation errors include field path, source location, and the offending value. The format varies by source type:

"""Error format — YAML source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "error_format_config.yaml"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port: 0
    |    │         ^
    |    └── FILE '{SOURCES_DIR}error_format_config.yaml', line 1
    +------------------------------------
"""Error format — JSON source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.JsonSource(file=SOURCES_DIR / "error_format_config.json"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── "port": 0
    |    │           ^
    |    └── FILE '{SOURCES_DIR}error_format_config.json', line 2
    +------------------------------------
"""Error format — JSON5 source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.Json5Source(file=SOURCES_DIR / "error_format_config.json5"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port: 0,
    |    │         ^
    |    └── FILE '{SOURCES_DIR}error_format_config.json5', line 2
    +------------------------------------
"""Error format — TOML source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.Toml11Source(file=SOURCES_DIR / "error_format_config.toml"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port = 0
    |    │          ^
    |    └── FILE '{SOURCES_DIR}error_format_config.toml', line 1
    +------------------------------------
"""Error format — INI source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.IniSource(
        file=SOURCES_DIR / "error_format_config.ini",
        prefix="app",
    ),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port = 0
    |    │          ^
    |    └── FILE '{SOURCES_DIR}error_format_config.ini', line 2
    +------------------------------------
"""Error format — ENV source."""

import os
from dataclasses import dataclass
from typing import Annotated

import dature
from dature import V

os.environ["APP_PORT"] = "0"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.EnvSource(prefix="APP_"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── APP_PORT=0
    |    │            ^
    |    └── ENV 'APP_PORT'
    +------------------------------------
"""Error format — ENV file source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.EnvFileSource(file=SOURCES_DIR / "error_format_config.env"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── PORT=0
    |    │        ^
    |    └── ENV FILE '{SOURCES_DIR}error_format_config.env', line 1
    +------------------------------------
"""Error format — Docker Secrets source."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    port: Annotated[int, V >= 1]


dature.load(
    dature.DockerSecretsSource(dir_=SOURCES_DIR / "error_format_docker"),
    schema=Config,
)
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [port]  Value must be greater than or equal to 1
    |    ├── port = 0
    |    │          ^
    |    └── SECRET FILE '{SOURCES_DIR}error_format_docker/port'
    +------------------------------------

Multi-line value#

When a value spans multiple source lines, each visible line is shown under the ├── prefix with a caret underlining it so the whole offending block is visible at a glance. Long values are truncated after a few lines:

"""Error format — value spans multiple source lines."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Config:
    tags: Annotated[list[str], V.unique_items()]


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "error_format_multiline.yaml"),
    schema=Config,
)
tags:
  - web
  - web
  - api
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [tags]  Value must contain unique items
    |    ├── tags:
    |    │   ^^^^^
    |    ├──   - web
    |    │     ^^^^^
    |    ├── ...
    |    └── FILE '{SOURCES_DIR}error_format_multiline.yaml', line 1-4
    +------------------------------------

Dataclass value#

A custom validator can be attached to a dataclass-typed field via Annotated. The error shows the whole nested block from the source:

"""Error format — custom validator on a dataclass-typed field."""

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import dature
from dature import V

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass
class Endpoint:
    host: str
    port: int


@dataclass
class Config:
    endpoint: Annotated[
        Endpoint,
        V.check(
            lambda ep: bool(ep.host),
            error_message="Endpoint host must not be empty",
        ),
    ]


dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "error_format_dataclass.yaml"),
    schema=Config,
)
endpoint:
  host: ""
  port: 8080
  | dature.errors.exceptions.DatureConfigError: Config loading errors (1)
  +-+---------------- 1 ----------------
    | dature.errors.exceptions.FieldLoadError:   [endpoint]  Endpoint host must not be empty
    |    ├── endpoint:
    |    │   ^^^^^^^^^
    |    ├──   host: ""
    |    │     ^^^^^^^^
    |    ├──   port: 8080
    |    │     ^^^^^^^^^^
    |    └── FILE '{SOURCES_DIR}error_format_dataclass.yaml', line 1-3
    +------------------------------------

All field errors are collected and reported together — dature doesn't stop at the first error.