Source code for configguard.schema

# configguard/schema.py
import typing

from .exceptions import SchemaError, ValidationError
from .log import log

SUPPORTED_TYPES = {
    "str": str,
    "int": int,
    "float": float,
    "bool": bool,
    "list": list,
    # Add more complex types later if needed, e.g., dict
}


[docs] class SettingSchema: """Represents the schema definition for a single configuration setting."""
[docs] def __init__(self, name: str, definition: dict): """ Initializes the SettingSchema. Args: name: The name of the setting. definition: A dictionary defining the schema for this setting. Expected keys: 'type', 'help'. Optional keys: 'default', 'nullable', 'min_val', 'max_val', 'options'. """ self.name = name # Store the raw definition dictionary to check for explicit 'default' later self._raw_definition = definition log.debug( f"Initializing schema for setting '{name}' with definition: {definition}" ) if not isinstance(definition, dict): raise SchemaError(f"Schema definition for '{name}' must be a dictionary.") # Type (Mandatory) type_str = definition.get("type") if not type_str or not isinstance(type_str, str): raise SchemaError(f"Schema for '{name}' must include a 'type' string.") if type_str not in SUPPORTED_TYPES: raise SchemaError( f"Unsupported type '{type_str}' for setting '{name}'. Supported types: {list(SUPPORTED_TYPES.keys())}" ) self.type: typing.Type = SUPPORTED_TYPES[type_str] self.type_str: str = type_str # Nullable Flag (Optional) self.nullable: bool = definition.get("nullable", False) if not isinstance(self.nullable, bool): raise SchemaError( f"Schema 'nullable' flag for '{name}' must be a boolean (True or False)." ) # Default Value (Conditionally Mandatory) if "default" in definition: self.default_value: typing.Any = definition["default"] elif self.nullable: # If nullable and no default specified, default is None self.default_value: typing.Any = None log.debug( f"Setting '{name}' is nullable and has no default specified. Defaulting to None." ) else: # If not nullable and no default, raise error raise SchemaError( f"Schema for non-nullable setting '{name}' must include a 'default' value." ) # Help Text (Mandatory) self.help: str = definition.get("help", "") if not self.help or not isinstance(self.help, str): log.warning( f"Schema for '{name}' should include a non-empty 'help' string for documentation." ) self.help = "No help provided." # Provide a default help string # Options (Optional, for specific types like str, int, float) self.options: typing.Optional[list] = definition.get("options") if self.options is not None: if not isinstance(self.options, list): raise SchemaError(f"Schema 'options' for '{name}' must be a list.") if not self.options: raise SchemaError( f"Schema 'options' for '{name}' cannot be an empty list." ) # Ensure options are valid for the type (check after default validation) # We'll validate options compatibility during default/value validation. # Min/Max Value (Optional, for numeric types int, float) self.min_val: typing.Optional[typing.Union[int, float]] = definition.get( "min_val" ) self.max_val: typing.Optional[typing.Union[int, float]] = definition.get( "max_val" ) if self.type not in [int, float]: if self.min_val is not None or self.max_val is not None: log.warning( f"'min_val'/'max_val' are only applicable to int/float types. Ignored for '{name}' (type: {self.type_str})." ) self.min_val = None self.max_val = None else: if self.min_val is not None and not isinstance(self.min_val, (int, float)): raise SchemaError( f"'min_val' for numeric setting '{name}' must be a number (int or float)." ) if self.max_val is not None and not isinstance(self.max_val, (int, float)): raise SchemaError( f"'max_val' for numeric setting '{name}' must be a number (int or float)." ) if ( self.min_val is not None and self.max_val is not None and self.min_val > self.max_val ): raise SchemaError( f"'min_val' ({self.min_val}) cannot be greater than 'max_val' ({self.max_val}) for setting '{name}'." ) # Final validation of default value after setting constraints try: # Validate default *unless* it's None and nullable is True if not (self.nullable and self.default_value is None): self.validate(self.default_value) # Validate first # Coerce default value if necessary *after* validation self.default_value = self._coerce_value(self.default_value) # else: Default is None and nullable is True, so it's valid and needs no coercion. # Now validate that options are compatible with the validated default if self.options is not None and self.default_value is not None: # Coerce options for comparison if needed (especially for bool) coerced_options = [self._coerce_value(opt) for opt in self.options] # Allow default None even if options are set, if nullable if not (self.nullable and self.default_value is None): if self.default_value not in coerced_options: raise SchemaError( f"Default value '{self.default_value}' for setting '{name}' is not among the allowed options: {self.options}." ) except ValidationError as e: raise SchemaError( f"Default value '{self.default_value}' for setting '{name}' failed validation: {e}" ) except SchemaError as e: # Catch option validation error raise e log.debug(f"Schema initialized successfully for '{name}'.")
def _coerce_value(self, value: typing.Any) -> typing.Any: """Attempts to coerce the value to the schema type, handling None if nullable.""" if value is None: return None if self.type is bool: # Strict boolean coercion if isinstance(value, bool): return value # Already a bool, no coercion needed if isinstance(value, str): val_lower = value.lower() if val_lower == "true": return True if val_lower == "false": return False # Allow 0/1 for bool type as well if isinstance(value, (int, float)): if value == 1: return True if value == 0: return False # If none of the above match, it's likely an invalid bool representation. # Let validation handle the type mismatch. # We return the original value here so validate can report the original type. log.debug( f"Could not strictly coerce '{value}' (type: {type(value).__name__}) to bool. Passing original value to validation." ) return value # Pass original value to validation # --- Rest of the _coerce_value method remains the same --- if self.type is list and isinstance(value, str): pass try: # Standard type conversion for non-bool types if type doesn't match if not isinstance(value, self.type): # Avoid converting if it's already the correct type or if target is list/dict (handle those separately if needed) if self.type not in [list, dict]: log.debug( f"Attempting standard coercion for {value} to {self.type_str}" ) return self.type(value) return value # Return original if already correct type or complex type except (ValueError, TypeError) as e: log.warning( f"Standard coercion failed for '{value}' to type {self.type_str}: {e}. Passing original value to validation." ) # Let validation handle the error if coercion fails return value
[docs] def validate(self, value: typing.Any): """ Validates a value against the schema definition. Args: value: The value to validate. Raises: ValidationError: If the value is invalid according to the schema. """ # 1. Nullability Check --- NEW --- if value is None: if self.nullable: log.debug(f"Allowing None for nullable setting '{self.name}'.") return # Valid: None is allowed else: # Raise error if None is provided but not allowed raise ValidationError( f"Value for setting '{self.name}' cannot be None (nullable is False)." ) coerced_value = self._coerce_value(value) # 1. Type Check if not isinstance(coerced_value, self.type): # Special check for bool allowing 0/1 if type is int/float for bool field if not ( self.type is bool and isinstance(coerced_value, (int, float)) and coerced_value in [0, 1] ): raise ValidationError( f"Value '{value}' for setting '{self.name}' must be of type '{self.type_str}', but got '{type(value).__name__}'." ) # 2. Options Check if self.options is not None: # Handle bool string options if self.type is bool and isinstance(coerced_value, bool): allowed_options = [self._coerce_value(opt) for opt in self.options] if coerced_value not in allowed_options: raise ValidationError( f"Value '{value}' for setting '{self.name}' is not one of the allowed options: {self.options}." ) elif coerced_value not in self.options: raise ValidationError( f"Value '{value}' for setting '{self.name}' is not one of the allowed options: {self.options}." ) # 3. Min/Max Check (for numeric types) if self.type in [int, float]: if self.min_val is not None and coerced_value < self.min_val: raise ValidationError( f"Value '{value}' for setting '{self.name}' is below the minimum allowed value of {self.min_val}." ) if self.max_val is not None and coerced_value > self.max_val: raise ValidationError( f"Value '{value}' for setting '{self.name}' is above the maximum allowed value of {self.max_val}." ) # 4. List element type check (basic) if self.type is list: # Currently, we don't enforce element types within lists in the schema definition itself. # This could be added with a syntax like "type": "list[str]" or "element_type": "str" # For now, we just check if it's a list. pass log.debug( f"Validation successful for setting '{self.name}' with value '{value}'." )
[docs] def to_dict(self) -> dict: """Returns a dictionary representation of the schema, suitable for JSON serialization.""" schema_dict = { "type": self.type_str, # Add help and nullable flags "help": self.help, "nullable": self.nullable, } # Include default value in the schema output # - Always include if it was explicitly defined in the raw input # - Include if it's not None (covers non-nullable defaults) # - Include if it's None AND nullable is True (covers explicit null default or implicit null for nullable) if ( "default" in self._raw_definition or self.default_value is not None or (self.default_value is None and self.nullable) ): schema_dict["default"] = self.default_value # Add other optional fields if they exist on the instance if self.options is not None: schema_dict["options"] = self.options if self.min_val is not None: schema_dict["min_val"] = self.min_val if self.max_val is not None: schema_dict["max_val"] = self.max_val return schema_dict
def __repr__(self) -> str: return f"SettingSchema(name='{self.name}', type='{self.type_str}', default={self.default_value})"