Validation¶
In this tutorial, you’ll learn how to validate configuration values using Varlord’s built-in validators, required field validation, and custom validation logic.
Learning Objectives¶
By the end of this tutorial, you’ll be able to:
Use explicit required/optional field metadata
Validate required fields are provided
Use built-in validators to validate configuration values
Create custom validation logic
Understand when validation occurs
Handle validation errors
Step 1: Required/Optional Fields¶
Fields are automatically determined as required/optional:
1from dataclasses import dataclass, field
2from typing import Optional
3from varlord import Config
4
5@dataclass(frozen=True)
6class AppConfig:
7 host: str = field(default="0.0.0.0") # Optional (has default)
8 port: int = field(default=8000) # Optional (has default)
9 api_key: str = field() # Required (no default, not Optional)
10 timeout: Optional[int] = field() # Optional (Optional type)
11
12# This will raise RequiredFieldError if api_key is not provided
13from varlord.model_validation import RequiredFieldError
14
15cfg = Config(
16 model=AppConfig,
17 sources=[], # No sources provide api_key
18)
19
20try:
21 app = cfg.load()
22except RequiredFieldError as e:
23 print(f"Missing required fields: {e.missing_fields}")
Key Points:
Fields without defaults and not Optional[T] are required
Fields with Optional[T] type annotation are optional
Fields with defaults (or
default_factory) are optionalRequired fields must be provided by at least one source
Empty strings and empty collections are considered valid (presence is checked, not emptiness)
Step 2: Basic Value Validation¶
Let’s add validation to ensure configuration values are correct:
1from dataclasses import dataclass, field
2from varlord import Config
3from varlord.validators import validate_port, validate_not_empty
4
5@dataclass(frozen=True)
6class AppConfig:
7 host: str = field(default="0.0.0.0")
8 port: int = field(default=8000)
9
10 def __post_init__(self):
11 validate_not_empty(self.host)
12 validate_port(self.port)
13
14cfg = Config(
15 model=AppConfig,
16 sources=[],
17)
18
19app = cfg.load()
20print(f"Configuration valid: {app.host}:{app.port}")
Expected Output:
Configuration valid: 0.0.0.0:8000
Key Points:
Validation happens in
__post_init__Validation occurs after all sources are merged
If validation fails,
cfg.load()raisesValidationError
Step 3: Validation with Multiple Sources¶
Validation uses the final merged values, not just defaults:
1import os
2from dataclasses import dataclass, field
3from varlord import Config, sources
4from varlord.validators import validate_port, ValidationError
5
6@dataclass(frozen=True)
7class AppConfig:
8 port: int = field(default=8000) # Valid default
9
10 def __post_init__(self):
11 validate_port(self.port)
12
13# Set invalid port in environment
14os.environ["PORT"] = "70000" # Invalid (too large)
15
16cfg = Config(
17 model=AppConfig,
18 sources=[
19 sources.Env(),
20 ],
21)
22
23try:
24 app = cfg.load()
25except ValidationError as e:
26 print(f"Validation failed: {e.message}")
27 print(f" Key: {e.key}")
28 print(f" Value: {e.value}")
Expected Output:
Validation failed: must be <= 65535
Key: value
Value: 70000
Important: Even though the default value (8000) is valid, validation fails because the final merged value (70000 from env) is invalid.
Step 4: Using Multiple Validators¶
You can use multiple validators for a single field:
1from dataclasses import dataclass, field
2from varlord import Config, sources
3from varlord.validators import (
4 validate_email,
5 validate_url,
6 validate_length,
7 validate_not_empty,
8)
9
10@dataclass(frozen=True)
11class AppConfig:
12 admin_email: str = field(default="admin@example.com")
13 api_url: str = field(default="https://api.example.com")
14 api_key: str = field(default="")
15
16 def __post_init__(self):
17 validate_email(self.admin_email)
18 validate_url(self.api_url)
19 validate_not_empty(self.api_key) # This will fail with empty default!
20 validate_length(self.api_key, min_length=32, max_length=64)
21
22cfg = Config(
23 model=AppConfig,
24 sources=[],
25)
26
27try:
28 app = cfg.load()
29except ValidationError as e:
30 print(f"Validation failed: {e.message}")
Expected Output:
Validation failed: must not be empty
Solution: Provide a valid default or ensure another source provides the value:
1import os
2
3# Provide valid api_key from environment
4os.environ["API_KEY"] = "a" * 32 # 32 characters
5
6cfg = Config(
7 model=AppConfig,
8 sources=[
9 sources.Env(),
10 ],
11)
12
13app = cfg.load()
14print(f"API Key length: {len(app.api_key)}") # 32
Expected Output:
API Key length: 32
Step 5: Validating Nested Configuration¶
Each nested dataclass can have its own validation:
1from dataclasses import dataclass, field
2from varlord import Config
3from varlord.validators import validate_port, validate_not_empty
4
5@dataclass(frozen=True)
6class DBConfig:
7 host: str = field(default="localhost")
8 port: int = field(default=5432)
9
10 def __post_init__(self):
11 validate_not_empty(self.host)
12 validate_port(self.port)
13
14@dataclass(frozen=True)
15class AppConfig:
16 host: str = field(default="0.0.0.0")
17 port: int = field(default=8000)
18 db: DBConfig = field(default_factory=lambda: DBConfig())
19
20 def __post_init__(self):
21 validate_port(self.port)
22 # DBConfig's __post_init__ is automatically called
23 # No need to manually validate self.db
24
25cfg = Config(
26 model=AppConfig,
27 sources=[],
28)
29
30app = cfg.load()
31print(f"App port: {app.port}")
32print(f"DB port: {app.db.port}")
Expected Output:
App port: 8000
DB port: 5432
Key Points:
Nested objects are validated automatically when created
Parent’s
__post_init__is called after nested objects are validatedYou can add cross-field validation in the parent’s
__post_init__
Step 6: Cross-Field Validation¶
You can validate relationships between fields:
1from dataclasses import dataclass, field
2from varlord import Config
3from varlord.validators import validate_port, ValidationError
4
5@dataclass(frozen=True)
6class AppConfig:
7 app_port: int = field(default=8000)
8 db_port: int = field(default=8000) # Same as app_port - will conflict!
9
10 def __post_init__(self):
11 validate_port(self.app_port)
12 validate_port(self.db_port)
13
14 # Cross-field validation
15 if self.app_port == self.db_port:
16 raise ValidationError(
17 "app_port",
18 self.app_port,
19 f"App port conflicts with DB port {self.db_port}"
20 )
21
22# This will fail - ports conflict
23cfg = Config(
24 model=AppConfig,
25 sources=[],
26)
27
28try:
29 app = cfg.load()
30except ValidationError as e:
31 print(f"Validation failed: {e.message}")
Expected Output:
Validation failed: App port conflicts with DB port 8000
Step 7: Custom Validators¶
You can create custom validation functions:
1from dataclasses import dataclass, field
2from varlord import Config
3from varlord.validators import ValidationError
4
5def validate_api_key_format(value: str) -> None:
6 """Custom validator for API key format."""
7 if not value.startswith("sk-"):
8 raise ValidationError(
9 "api_key",
10 value,
11 "API key must start with 'sk-'"
12 )
13 if len(value) < 32:
14 raise ValidationError(
15 "api_key",
16 value,
17 "API key must be at least 32 characters"
18 )
19
20@dataclass(frozen=True)
21class AppConfig:
22 api_key: str = field(default="")
23
24 def __post_init__(self):
25 validate_api_key_format(self.api_key)
26
27# This will fail with empty default
28cfg = Config(
29 model=AppConfig,
30 sources=[],
31)
32
33try:
34 app = cfg.load()
35except ValidationError as e:
36 print(f"Validation failed: {e.message}")
Expected Output:
Validation failed: API key must start with 'sk-'
Step 8: Complete Example¶
Here’s a complete example with comprehensive validation:
1import os
2from dataclasses import dataclass, field
3from varlord import Config, sources
4from varlord.validators import (
5 validate_port,
6 validate_email,
7 validate_url,
8 validate_length,
9 validate_not_empty,
10 ValidationError,
11)
12
13@dataclass(frozen=True)
14class DBConfig:
15 host: str = field(default="localhost")
16 port: int = field(default=5432)
17
18 def __post_init__(self):
19 validate_not_empty(self.host)
20 validate_port(self.port)
21
22@dataclass(frozen=True)
23class AppConfig:
24 host: str = field(default="0.0.0.0")
25 port: int = field(default=8000)
26 admin_email: str = field(default="admin@example.com")
27 api_url: str = field(default="https://api.example.com")
28 api_key: str = field(default="")
29 db: DBConfig = field(default_factory=lambda: DBConfig())
30
31 def __post_init__(self):
32 validate_not_empty(self.host)
33 validate_port(self.port)
34 validate_email(self.admin_email)
35 validate_url(self.api_url)
36 validate_length(self.api_key, min_length=32, max_length=64)
37
38 # Cross-field validation
39 if self.port == self.db.port:
40 raise ValidationError(
41 "port",
42 self.port,
43 f"App port conflicts with DB port {self.db.port}"
44 )
45
46def main():
47 # Provide required values from environment
48 os.environ["API_KEY"] = "a" * 32
49
50 cfg = Config(
51 model=AppConfig,
52 sources=[
53 sources.Env(),
54 ],
55 )
56
57 try:
58 app = cfg.load()
59 print("Configuration validated successfully:")
60 print(f" App: {app.host}:{app.port}")
61 print(f" Admin: {app.admin_email}")
62 print(f" API: {app.api_url}")
63 print(f" API Key: {'*' * len(app.api_key)}")
64 print(f" DB: {app.db.host}:{app.db.port}")
65 except ValidationError as e:
66 print(f"Validation failed: {e.message}")
67 print(f" Key: {e.key}")
68 print(f" Value: {e.value}")
69
70if __name__ == "__main__":
71 main()
Expected Output:
Configuration validated successfully:
App: 0.0.0.0:8000
Admin: admin@example.com
API: https://api.example.com
API Key: ********************************
DB: localhost:5432
Common Pitfalls¶
Pitfall 1: Validating before sources are merged
@dataclass(frozen=True)
class AppConfig:
api_key: str = ""
def __post_init__(self):
# This validates the FINAL merged value, not just the default
validate_length(self.api_key, min_length=32)
# If no source provides api_key, this will fail even if
# you intended to provide it via environment
Solution: Remember that validation happens after all sources are merged.
Ensure at least one source provides valid values, or use Optional for
fields that may not always be set.
Pitfall 3: Not handling RequiredFieldError
@dataclass(frozen=True)
class AppConfig:
api_key: str = field()
app = cfg.load() # May raise RequiredFieldError if api_key not provided
print(app.api_key) # This line won't execute if validation fails
Solution: Either provide the required field via a source, or use cfg.load(validate=False) to skip validation, or wrap in try-except to handle RequiredFieldError.
Pitfall 4: Not handling ValidationError
app = cfg.load() # May raise ValidationError
print(app.port) # This line won't execute if validation fails
Solution: Always wrap cfg.load() in try-except to handle validation
errors gracefully.
Pitfall 5: Validating nested objects manually
@dataclass(frozen=True)
class AppConfig:
db: DBConfig = field(default_factory=lambda: DBConfig())
def __post_init__(self):
# Unnecessary - DBConfig.__post_init__ is already called
if self.db:
validate_port(self.db.port) # Redundant!
Solution: Nested objects are automatically validated. Only add validation in the parent if you need cross-field validation.
Best Practices¶
Fields are automatically determined: Use
Optional[T]type annotation or default values for optional fieldsValidate required fields: Use
Config.validate()orcfg.load(validate=True)to ensure required fields are providedValidate field values: Use validators in
__post_init__to ensure data integrityProvide helpful error messages: Custom validators should explain what’s wrong
Validate after merge: Remember validation uses final merged values
Use built-in validators when possible: They’re tested and well-documented
Add field descriptions: Use
metadata={"description": "..."}for better documentation
Next Steps¶
Now that you understand validation, let’s learn about Dynamic Updates to handle configuration changes at runtime.