Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/specimen/util/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__all__ = ["util", "set_up"]

from . import util, set_up
from .config_validator import validate_config, SchemaField, ConfigValidationError
Comment thread
dyrpsf marked this conversation as resolved.
Outdated
130 changes: 130 additions & 0 deletions src/specimen/util/config_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import os
import logging
from typing import Any, Dict, List, Optional, Union, Callable
Comment thread
dyrpsf marked this conversation as resolved.
Outdated

logger = logging.getLogger(__name__)


class ConfigValidationError(Exception):
"""Custom exception for configuration validation errors."""

pass


class SchemaField:
"""Defines the expected structure, type, and constraints of a config field."""

def __init__(
self,
expected_type: type | tuple,
default: Any = None,
required: bool = False,
min_val: Optional[Union[int, float]] = None,
max_val: Optional[Union[int, float]] = None,
is_path: bool = False,
path_must_exist: bool = False,
allowed_values: Optional[List[Any]] = None,
):
self.expected_type = expected_type
self.default = default
self.required = required
self.min_val = min_val
self.max_val = max_val
self.is_path = is_path
self.path_must_exist = path_must_exist
self.allowed_values = allowed_values


def validate_config(
config: Dict[str, Any], schema: Dict[str, Any], path: str = ""
) -> Dict[str, Any]:
"""
Recursively validates a configuration dictionary against a defined schema.
Replaces verbose YAML traversal and USER/_USER_ string checks.
"""
validated = {}

for key, rule in schema.items():
current_path = f"{path}.{key}" if path else key
value = config.get(key, rule.default if isinstance(rule, SchemaField) else None)

# Handle nested dictionaries
if isinstance(rule, dict):
if not isinstance(value, dict):
if value is None:
value = {}
else:
raise ConfigValidationError(
f"Expected a dictionary at '{current_path}', got {type(value).__name__}."
)
validated[key] = validate_config(value, rule, current_path)
continue

if not isinstance(rule, SchemaField):
raise ConfigValidationError(
f"Invalid schema definition at '{current_path}'."
)

# 1. Check Required fields (Replaces _USER_ logic)
if value is None or value == "_USER_":
if rule.required:
raise ConfigValidationError(
f"Missing required configuration parameter: '{current_path}'"
)
elif value == "_USER_":
logger.error(
f"Deprecated '_USER_' found at '{current_path}'. Please provide a value or remove it."
)
raise ConfigValidationError(
f"Missing required parameter: '{current_path}'"
)

# Replaces USER -> None logic with sensible defaults
if value == "USER":
logger.warning(
f"Deprecated 'USER' keyword found at '{current_path}'. Using default: {rule.default}"
)
validated[key] = rule.default
continue
Comment thread
dyrpsf marked this conversation as resolved.
Outdated

# Clean up legacy 'USER' keyword
if value == "USER":
logger.warning(
f"Deprecated 'USER' keyword found at '{current_path}'. Using default: {rule.default}"
)
value = rule.default

# 2. Type Checking
if value is not None and not isinstance(value, rule.expected_type):
raise ConfigValidationError(
f"Type mismatch at '{current_path}': Expected {rule.expected_type}, got {type(value)}."
)
Comment thread
dyrpsf marked this conversation as resolved.

# 3. Numeric Bounds Checking
if isinstance(value, (int, float)):
if rule.min_val is not None and value < rule.min_val:
raise ConfigValidationError(
f"Value at '{current_path}' ({value}) is below minimum allowed ({rule.min_val})."
)
if rule.max_val is not None and value > rule.max_val:
raise ConfigValidationError(
f"Value at '{current_path}' ({value}) is above maximum allowed ({rule.max_val})."
)

# 4. Allowed Values Checking (Strings, etc.)
if rule.allowed_values is not None and value not in rule.allowed_values:
raise ConfigValidationError(
f"Value at '{current_path}' ({value}) must be one of {rule.allowed_values}."
)

# 5. Path Validation
if rule.is_path and value is not None:
value = str(value)
if rule.path_must_exist and not os.path.exists(value):
raise ConfigValidationError(
f"Path specified at '{current_path}' does not exist: {value}"
)

validated[key] = value

return validated