Python Snippets

JSON Configuration File Parser with Validation and Default Values

import json
from typing import Any, Dict, Optional, Union
from pathlib import Path

class ConfigManager:
    """A robust configuration manager that loads, validates, and provides default values for JSON config files."""
    
    def __init__(self, config_path: Union[str, Path], schema: Dict[str, Any]):
        """
        Initialize the configuration manager.
        
        Args:
            config_path: Path to the JSON configuration file
            schema: Dictionary defining expected keys, types, and default values
        """
        self.config_path = Path(config_path)
        self.schema = schema
        self._config = {}
        self.load_config()
    
    def load_config(self) -> None:
        """Load configuration from file and validate against schema."""
        # Load config file if it exists, otherwise start with empty dict
        if self.config_path.exists():
            with open(self.config_path, 'r') as f:
                try:
                    file_config = json.load(f)
                except json.JSONDecodeError as e:
                    raise ValueError(f"Invalid JSON in config file: {e}")
        else:
            file_config = {}
        
        # Validate and apply defaults
        self._config = self._validate_and_apply_defaults(file_config)
        
        # Save back to file with defaults filled in
        self.save_config()
    
    def _validate_and_apply_defaults(self, config: Dict[str, Any]) -> Dict[str, Any]:
        """Validate config against schema and apply default values where needed."""
        validated_config = {}
        
        for key, spec in self.schema.items():
            # Extract type, default, and required status from schema
            expected_type = spec.get('type')
            default_value = spec.get('default')
            required = spec.get('required', False)
            
            # Get value from config or use default
            if key in config:
                value = config[key]
                # Type checking
                if expected_type and not isinstance(value, expected_type):
                    raise TypeError(f"Config key '{key}' should be of type {expected_type.__name__}, got {type(value).__name__}")
                validated_config[key] = value
            elif default_value is not None:
                validated_config[key] = default_value
            elif required:
                raise ValueError(f"Required config key '{key}' is missing and has no default")
            else:
                # Key not present, not required, and no default - skip it
                pass
        
        return validated_config
    
    def get(self, key: str, default: Any = None) -> Any:
        """Get a configuration value with an optional default."""
        return self._config.get(key, default)
    
    def set(self, key: str, value: Any) -> None:
        """Set a configuration value."""
        # Validate type if key exists in schema
        if key in self.schema:
            expected_type = self.schema[key].get('type')
            if expected_type and not isinstance(value, expected_type):
                raise TypeError(f"Value for '{key}' should be of type {expected_type.__name__}")
        
        self._config[key] = value
    
    def save_config(self) -> None:
        """Save current configuration to file."""
        # Create parent directories if they don't exist
        self.config_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(self.config_path, 'w') as f:
            json.dump(self._config, f, indent=2, sort_keys=True)
    
    @property
    def config(self) -> Dict[str, Any]:
        """Get a copy of the entire configuration."""
        return self._config.copy()

# Example usage
if __name__ == "__main__":
    # Define configuration schema
    config_schema = {
        "debug": {
            "type": bool,
            "default": False,
            "required": False
        },
        "port": {
            "type": int,
            "default": 8000,
            "required": False
        },
        "database_url": {
            "type": str,
            "default": "sqlite:///app.db",
            "required": True
        },
        "max_connections": {
            "type": int,
            "default": 100,
            "required": False
        },
        "features": {
            "type": list,
            "default": [],
            "required": False
        }
    }
    
    # Initialize config manager
    config = ConfigManager("app_config.json", config_schema)
    
    # Access configuration values
    print(f"Debug mode: {config.get('debug')}")
    print(f"Server port: {config.get('port')}")
    print(f"Database URL: {config.get('database_url')}")
    
    # Modify configuration
    config.set('debug', True)
    config.set('port', 3000)
    
    # Save changes
    config.save_config()
    
    print("Configuration saved!")

What This Code Does

This snippet implements a robust configuration manager that handles loading, validating, and managing application settings stored in a JSON file. It provides a clean interface for accessing configuration values while ensuring type safety and applying default values where appropriate.

The ConfigManager class:

Why This Is Useful

Configuration management is a common need in applications, but doing it properly requires handling many details:

This implementation addresses all these concerns in a reusable way. It’s especially valuable for:

How to Run It

  1. Save the code to a file (e.g., config_manager.py)
  2. Run it directly: python config_manager.py
  3. It will create an app_config.json file with default values
  4. You can then modify the configuration in code or by editing the JSON file directly

To use in your own project:

  1. Define your configuration schema as a dictionary
  2. Create a ConfigManager instance with your schema
  3. Use config.get() to retrieve values and config.set() to update them
  4. Call config.save_config() to persist changes

The first run will create a configuration file with defaults. Subsequent runs will load existing values while maintaining type safety and applying any new defaults from the schema.