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:
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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,
)
| 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 predicates#
| Expression | Passes when | Type constraint |
|---|---|---|
V >= N / V > N / V <= N / V < N / V == N / V != N | comparison holds | any comparable |
V.len() >= N (and >, <=, <, ==, !=) | length comparison holds | Sized |
V.matches(pattern) | re.match(pattern, value) succeeds | str |
V.in_([...]) | value is in the collection | any |
V.unique_items() | all items are unique | Collection |
V.each(inner) | inner passes for every item | Iterable |
V.check(func, error_message=...) | func(value) returns True | any |
Compose predicates with & (AND), | (OR), ~ (NOT). Override the default error message with .with_error_message("...") on leaf predicates.
Warning
Chained comparisons like 3 <= V.len() <= 10 are not supported — Python collapses them to a bool before dature sees them. Use (V.len() >= 3) & (V.len() <= 10).
Root Validators#
Validate the entire object after loading:
from dataclasses import dataclass
import dature
from dature import V
@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",
),
schema=Config,
root_validators=(
V.root(
check_debug_not_on_production,
error_message=("debug=True is not allowed on non-localhost hosts"),
),
),
)
| 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. Pass them via root_validators= on load(), Loader, or configure() — they run once on the final merged object, after all sources have been applied.
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:
from dataclasses import dataclass
import dature
from dature import V
@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,
)
| 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:
```python
from dataclasses import dataclass
import dature
from dature import V
@dataclass
class Config:
host: str
port: int
validators = {
dature.F[Config].port: (V > 0) & (V < 65536),
dature.F[Config].host: V.len() >= 1,
}
validators_tuple = {
dature.F[Config].port: (V > 0, V < 65536),
}
```
Nested fields are supported via F[Config].field — see Field Paths:
```python
from dataclasses import dataclass
import dature
from dature import V
@dataclass
class Database:
host: str
port: int
@dataclass
class Config:
database: Database
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:
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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,
)
| 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.
Validators During Merging#
Skip-invalid probe#
When skip_invalid_fields=True, dature silently drops any field whose value fails coercion or a field validator (Annotated predicates and source.validators). Business-rule violations cause the field to be omitted rather than an error being raised.
Per-source validator semantics#
Field validators (Annotated predicates and source.validators) fire per-source, only on fields that the source actually provided, on the coerced value:
- A field provided by multiple sources is validated once per source that provides it.
- A field that a source did not provide is not validated by that source's pass — no missing-field error is raised for absent fields.
- A field that comes solely from a dataclass default is validated once at the end on the final object.
Root validators (root_validators=) run once on the final merged object after all field validation passes have completed.
__post_init__ and @property#
Standard dataclass __post_init__ and @property work as expected — dature preserves them during loading:
from dataclasses import dataclass
import dature
@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,
)
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:
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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
+------------------------------------
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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:
| 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:
from dataclasses import dataclass
from typing import Annotated
import dature
from dature import V
@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,
)
| 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.