Source code for varlord.model_validation

"""
Model and configuration structure validation.

This module provides validation functions for:
- Model definitions (checking required/optional metadata)
- Configuration structure (checking required fields are present)

For value validation (e.g., validate_port, validate_email), see varlord.validators.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin

from varlord.metadata import get_all_fields_info
from varlord.sources.base import Source


[docs] class VarlordError(Exception): """Base exception for varlord errors.""" pass
[docs] class ModelDefinitionError(VarlordError): """Raised when model definition is invalid. Currently no validation errors are raised - Optional[T] types are supported and automatically recognized as optional fields. """
[docs] def __init__(self, field_name: str, model_name: str, reason: str = "invalid"): """Initialize ModelDefinitionError. Args: field_name: Name of the field with the issue model_name: Name of the model class reason: Reason for the error """ self.field_name = field_name self.model_name = model_name self.reason = reason message = f"Field '{field_name}' in model '{model_name}' has an invalid definition." super().__init__(message)
[docs] class RequiredFieldError(VarlordError): """Raised when required fields are missing from configuration. This error is raised when a required field is not present in the merged configuration dictionary. """
[docs] def __init__( self, missing_fields: List[str], model_name: str, sources: List[Source], show_source_help: bool = True, field_infos: Optional[List[Any]] = None, config_dict: Optional[Dict[str, Any]] = None, ): """Initialize RequiredFieldError. Args: missing_fields: List of normalized keys of missing required fields model_name: Name of the model class sources: List of sources (for generating help examples) show_source_help: Whether to include source mapping help in error message field_infos: Optional list of FieldInfo objects for missing fields config_dict: Optional configuration dictionary for enhanced error messages """ self.missing_fields = missing_fields self.model_name = model_name self.sources = sources self.show_source_help = show_source_help self.field_infos = field_infos or [] self._config_dict = config_dict message = self._format_error_message() super().__init__(message)
def _format_error_message(self) -> str: """Format error message with missing fields and source help.""" lines = [ f"Required fields are missing in model '{self.model_name}':", "", ] # Create a mapping from field key to field info for quick lookup field_info_map = { field_info.normalized_key: field_info for field_info in self.field_infos if field_info.normalized_key in self.missing_fields } # Get config_dict if available (for checking child fields) config_dict = getattr(self, "_config_dict", None) # List missing fields with descriptions if available for field_key in self.missing_fields: field_info = field_info_map.get(field_key) if field_info and field_info.description: lines.append(f" - {field_key}: {field_info.description}") else: lines.append(f" - {field_key}") # Check if child fields exist for nested dataclass fields if config_dict is not None and field_info: from dataclasses import is_dataclass if is_dataclass(field_info.type): prefix = field_key + "." child_fields = [k for k in config_dict.keys() if k.startswith(prefix)] if child_fields: lines.append(f" Note: Child fields exist: {', '.join(child_fields[:5])}") if len(child_fields) > 5: lines.append(f" ... and {len(child_fields) - 5} more") lines.append( " This may indicate a validation logic issue with nested dataclass fields." ) # Add source help if enabled if self.show_source_help: try: from varlord.source_help import format_source_help help_text = format_source_help(self.sources, self.missing_fields) if help_text: lines.append("") lines.append(help_text) except ImportError: # source_help module not available yet, skip pass return "\n".join(lines)
def _is_optional_type(field_type: Type[Any]) -> bool: """Check if a type is Optional[T] or Union[T, None]. Args: field_type: Type to check Returns: True if the type is Optional[T] or Union[T, None] """ origin = get_origin(field_type) if origin is None: return False # Check for Optional[T] (which is Union[T, None]) if origin is Union: args = get_args(field_type) # Optional[T] is Union[T, None], so check if None is in args if type(None) in args: return True return False
[docs] def validate_model_definition(model: Type[Any]) -> None: """Validate model definition. Fields are determined as required/optional based on: 1. Type annotation: Optional[T] → optional 2. Default value: has default or default_factory → optional 3. Otherwise → required Args: model: Dataclass model to validate Example: >>> @dataclass ... class Config: ... api_key: str = field() # Required (no default, not Optional) ... timeout: Optional[int] = field() # Optional (Optional type) ... host: str = field(default="localhost") # Optional (has default) >>> validate_model_definition(Config) # OK """ # Currently no validation errors - Optional[T] types are supported # and automatically recognized as optional fields pass
[docs] def validate_config( model: Type[Any], config_dict: Dict[str, Any], sources: List[Source], show_source_help: bool = True, ) -> None: """Validate that all required fields exist in config_dict. Args: model: Dataclass model to validate against config_dict: Configuration dictionary to validate sources: List of sources (for generating help examples) show_source_help: Whether to include source mapping help in error message Raises: RequiredFieldError: If any required field is missing from config_dict Note: Only checks if keys exist in config_dict. Values can be None, empty string, or empty collections - these are all considered valid. Example: >>> @dataclass ... class Config: ... api_key: str = field() # Required by default >>> validate_config(Config, {}, []) RequiredFieldError: Required fields are missing... """ if not hasattr(model, "__name__"): model_name = str(model) else: model_name = model.__name__ # Get all field info field_infos = get_all_fields_info(model) # Find missing required fields missing_fields: List[str] = [] missing_field_infos: List[Any] = [] for field_info in field_infos: if field_info.required: # Check if key exists in config_dict if field_info.normalized_key in config_dict: continue # Field exists, skip # For nested dataclass fields, check if any child field exists from dataclasses import is_dataclass if is_dataclass(field_info.type): # Check if any child field exists prefix = field_info.normalized_key + "." has_child = any(key.startswith(prefix) for key in config_dict.keys()) if has_child: continue # Parent field is satisfied by child fields # Field is missing missing_fields.append(field_info.normalized_key) missing_field_infos.append(field_info) # Raise error if any required fields are missing if missing_fields: raise RequiredFieldError( missing_fields=missing_fields, model_name=model_name, sources=sources, show_source_help=show_source_help, field_infos=missing_field_infos, config_dict=config_dict, )