================================================================================ PRACTICAL PYTHON SCRIPTING - CHALLENGE 3 SOLUTION Chapter 2: Configuration & Environment Variables Challenge: Secure Configuration Management ================================================================================ PROBLEM: Create a script that: 1. Loads configuration with fallback defaults 2. Validates all required variables 3. Reports configuration status (what's set, what uses defaults) 4. Shows security warnings if DEBUG is true in production SOLUTION: ================================================================================ from dotenv import load_dotenv import os import sys from pathlib import Path class ConfigManager: """Load and validate application configuration.""" # Default values for optional settings DEFAULTS = { 'DB_PORT': '3306', 'DB_CHARSET': 'utf8mb4', 'DEBUG': 'false', 'LOG_LEVEL': 'info', 'ENVIRONMENT': 'development', 'TIMEOUT': '30', } # Required configuration keys REQUIRED = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS'] def __init__(self, env_file=None): """Initialize config manager and load .env file.""" if env_file is None: env_file = Path(__file__).parent / ".env" self.env_file = env_file self.config = {} self.warnings = [] self.errors = [] self.defaults_used = [] def load(self): """Load and validate configuration.""" # Load .env file if self.env_file.exists(): load_dotenv(self.env_file) print(f"šŸ“‚ Loaded configuration from {self.env_file}\n") else: print(f"āš ļø .env file not found at {self.env_file}") print(" Using defaults only\n") # Load configuration with validation self._load_config() self._validate() self._check_security() return self.config def _load_config(self): """Load configuration values with type conversion.""" print("=" * 60) print("LOADING CONFIGURATION") print("=" * 60 + "\n") # Database settings (required) self.config['db_host'] = os.environ.get('DB_HOST') self.config['db_name'] = os.environ.get('DB_NAME') self.config['db_user'] = os.environ.get('DB_USER') self.config['db_pass'] = os.environ.get('DB_PASS') # Database settings (optional with defaults) self.config['db_port'] = self._get_int( 'DB_PORT', int(self.DEFAULTS['DB_PORT']) ) self.config['db_charset'] = self._get_string( 'DB_CHARSET', self.DEFAULTS['DB_CHARSET'] ) # Application settings self.config['debug'] = self._get_bool( 'DEBUG', self.DEFAULTS['DEBUG'].lower() == 'true' ) self.config['log_level'] = self._get_string( 'LOG_LEVEL', self.DEFAULTS['LOG_LEVEL'] ).upper() self.config['environment'] = self._get_string( 'ENVIRONMENT', self.DEFAULTS['ENVIRONMENT'] ) self.config['timeout'] = self._get_int( 'TIMEOUT', int(self.DEFAULTS['TIMEOUT']) ) def _get_string(self, key, default): """Get string value with default fallback.""" value = os.environ.get(key) if value is None: self.defaults_used.append(key) return default return value def _get_int(self, key, default): """Get integer value with type conversion and error handling.""" value = os.environ.get(key) if value is None: self.defaults_used.append(key) return default try: return int(value) except ValueError: self.errors.append(f"{key} must be an integer, got '{value}'") return default def _get_bool(self, key, default): """Get boolean value with type conversion.""" value = os.environ.get(key) if value is None: self.defaults_used.append(key) return default return value.lower() in ('true', '1', 'yes', 'on') def _validate(self): """Validate that all required variables are set.""" print("šŸ” Validating configuration...\n") missing = [] for key in self.REQUIRED: env_key = key config_key = key.lower().replace('DB_', 'db_') if self.config[config_key] is None: missing.append(key) print(f" āŒ {key}: MISSING!") else: print(f" āœ… {key}: Set") if missing: self.errors.append(f"Missing required config: {', '.join(missing)}") # Check for defaults used if self.defaults_used: print(f"\nšŸ“Œ Using defaults for: {', '.join(self.defaults_used)}") print() def _check_security(self): """Check for security issues.""" print("šŸ”’ Security checks...\n") # Check if debug is enabled in production if self.config['environment'].lower() != 'development': if self.config['debug']: warning = ( f"āš ļø DEBUG MODE ENABLED in {self.config['environment'].upper()}! " "This is a security risk." ) self.warnings.append(warning) print(f" {warning}") # Check if sensitive values are present if not os.environ.get('DB_PASS'): warning = "āš ļø DB_PASS not found in environment (using default)" self.warnings.append(warning) print(f" {warning}") print() def report(self): """Print detailed configuration report.""" print("=" * 60) print("CONFIGURATION REPORT") print("=" * 60 + "\n") print("šŸ—„ļø Database Configuration:") print(f" Host: {self.config['db_host']}:{self.config['db_port']}") print(f" Database: {self.config['db_name']}") print(f" User: {self.config['db_user']}") print(f" Charset: {self.config['db_charset']}") print("\nāš™ļø Application Configuration:") print(f" Environment: {self.config['environment']}") print(f" Debug: {self.config['debug']}") print(f" Log Level: {self.config['log_level']}") print(f" Timeout: {self.config['timeout']}s") print("\nšŸ“Œ Configuration Status:") print(f" Defaults used: {len(self.defaults_used)}") print(f" Errors: {len(self.errors)}") print(f" Warnings: {len(self.warnings)}") if self.defaults_used: print(f"\n Values using defaults:") for key in self.defaults_used: print(f" - {key}") if self.warnings: print(f"\nāš ļø Security Warnings:") for warning in self.warnings: print(f" {warning}") if self.errors: print(f"\nāŒ Errors:") for error in self.errors: print(f" {error}") return len(self.errors) == 0 def is_valid(self): """Check if configuration is valid (no errors).""" return len(self.errors) == 0 # Main usage if __name__ == "__main__": # Create config manager config = ConfigManager() # Load configuration settings = config.load() # Print detailed report is_valid = config.report() print("\n" + "=" * 60) if is_valid: print("āœ… Configuration is valid and ready to use!") else: print("āŒ Configuration has errors - cannot proceed") sys.exit(1) print("=" * 60) ================================================================================ HOW IT WORKS: ================================================================================ 1. CLASS STRUCTURE class ConfigManager: DEFAULTS = {...} REQUIRED = [...] - Encapsulate configuration logic in a class - Define defaults and required keys as class variables - Keeps code organized and reusable 2. INITIALIZATION def __init__(self, env_file=None): - Accept optional env_file parameter - Default to looking for .env in script directory - Initialize empty config and error tracking 3. LOADING WITH DEFAULTS def _get_string(self, key, default): value = os.environ.get(key) if value is None: self.defaults_used.append(key) return default - Check if environment variable exists - If missing, use default and track it - Same pattern for different types (int, bool, string) 4. TYPE CONVERSION WITH ERROR HANDLING def _get_int(self, key, default): try: return int(value) except ValueError: self.errors.append(f"...") return default - Try to convert to target type - Catch errors and store them - Return default to prevent crash 5. VALIDATION def _validate(self): for key in self.REQUIRED: if self.config[key] is None: self.errors.append(...) - Check each required field - Report which are missing - Store errors for later reporting 6. SECURITY CHECKS if self.config['environment'].lower() != 'development': if self.config['debug']: self.warnings.append(...) - Check for debug mode in production - Check for missing sensitive values - Generate warnings for operators 7. REPORTING def report(self): - Print comprehensive configuration status - Show what's set, what uses defaults - Display errors and warnings - Return success/failure status ================================================================================ TESTING THE SOLUTION: ================================================================================ Test 1: With valid .env cat > .env << 'EOF' DB_HOST=localhost DB_NAME=testdb DB_USER=testuser DB_PASS=testpass123 ENVIRONMENT=development DEBUG=false EOF python config_secure.py Output: āœ… Configuration is valid and ready to use! --- Test 2: With missing password (Remove DB_PASS from .env) python config_secure.py Output: šŸ” Validating configuration... āŒ DB_PASS: MISSING! āŒ Configuration has errors - cannot proceed --- Test 3: With debug enabled in production DB_PASS=secret ENVIRONMENT=production DEBUG=true python config_secure.py Output: šŸ”’ Security checks... āš ļø DEBUG MODE ENABLED in PRODUCTION! This is a security risk. āš ļø Security Warnings: āš ļø DEBUG MODE ENABLED in PRODUCTION! This is a security risk. --- Test 4: Using defaults (Only set the required fields, let others use defaults) DB_HOST=localhost DB_NAME=testdb DB_USER=testuser DB_PASS=testpass python config_secure.py Output: šŸ“Œ Configuration Status: Defaults used: 4 Values using defaults: - DB_PORT - DB_CHARSET - DEBUG - LOG_LEVEL ================================================================================ ADVANCED PATTERNS: ================================================================================ Pattern 1: Environment-specific validation def _validate_environment(self): if self.config['environment'] == 'production': # Stricter validation for production if self.config['debug']: self.errors.append("DEBUG cannot be true in production") if not self.config['db_pass']: self.errors.append("DB_PASS is required in production") Pattern 2: Configuration presets PRESETS = { 'development': { 'DEBUG': True, 'LOG_LEVEL': 'debug', 'TIMEOUT': '60', }, 'production': { 'DEBUG': False, 'LOG_LEVEL': 'warning', 'TIMEOUT': '10', } } Pattern 3: Save/load configuration as JSON import json def save_config(self): with open('config.json', 'w') as f: json.dump(self.config, f, indent=2) def load_config(self): with open('config.json', 'r') as f: self.config = json.load(f) ================================================================================ REAL-WORLD USAGE: ================================================================================ In production code, you'd use it like this: # app.py from config import ConfigManager import sys # Load configuration at startup config = ConfigManager() settings = config.load() if not config.is_valid(): print("Configuration errors. Cannot start.") sys.exit(1) # Use configuration throughout app db_host = settings['db_host'] debug = settings['debug'] # In database module: import mysql.connector def connect_to_database(config): return mysql.connector.connect( host=config['db_host'], user=config['db_user'], password=config['db_pass'], database=config['db_name'], port=config['db_port'], charset=config['db_charset'], ) ================================================================================ KEY TAKEAWAYS: ================================================================================ āœ… Use classes to organize configuration logic āœ… Define DEFAULTS and REQUIRED as constants āœ… Always use fallback defaults for optional settings āœ… Track which values use defaults (transparency) āœ… Use try/except for all type conversions āœ… Validate required fields before using config āœ… Check for security issues (debug in production) āœ… Report configuration status clearly āœ… Return success/failure indicator from validation ConfigManager Pattern: 1. Load .env file with load_dotenv() 2. Get each config value with defaults 3. Convert types safely with try/except 4. Validate all required fields exist 5. Check for security issues 6. Report comprehensive status 7. Return config if valid, exit if not This pattern scales from simple scripts to complex applications! ================================================================================