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,
)
| 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,
)
| 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,
)
| 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,
)
| 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,
)
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 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,
)
| 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,
)
| 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.