#!/usr/bin/env python3 """ config_overlay.py — Environment-specific config overlays. Merges a base config YAML with an environment-specific overlay. Overlay keys override base keys (deep merge for dicts, replace for lists). Usage: from config_overlay import load_config config = load_config('config.yaml', env='prod') # Loads config.yaml + config.prod.yaml, merges, returns dict Environments: dev, prod, cron, gateway (or custom) """ import os import copy import yaml from pathlib import Path def deep_merge(base: dict, overlay: dict) -> dict: """Deep merge overlay into base. Overlay wins on conflicts. - Dicts are merged recursively - Lists are replaced (not extended) - Scalars are replaced - None in overlay removes the key from base """ result = copy.deepcopy(base) for key, value in overlay.items(): if value is None: result.pop(key, None) elif key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) else: result[key] = copy.deepcopy(value) return result def find_config(base_path: str, env: str = None) -> tuple: """Find base and overlay config paths. Returns: (base_path, overlay_path_or_None) """ base = Path(base_path) if not base.exists(): raise FileNotFoundError(f"Base config not found: {base_path}") overlay = None if env: # Try: config.{env}.yaml, config/{env}.yaml, {env}.overlay.yaml candidates = [ base.parent / f"{base.stem}.{env}{base.suffix}", base.parent / env / f"{base.name}", base.parent / f"{env}.overlay{base.suffix}", ] for c in candidates: if c.exists(): overlay = c break return base, overlay def load_config(base_path: str, env: str = None) -> dict: """Load base config and merge with environment overlay. Args: base_path: Path to base config YAML env: Environment name (dev, prod, cron, gateway, or custom) Returns: Merged config dict """ base_file, overlay_file = find_config(base_path, env) with open(base_file) as f: base_config = yaml.safe_load(f) or {} if overlay_file and overlay_file.exists(): with open(overlay_file) as f: overlay_config = yaml.safe_load(f) or {} return deep_merge(base_config, overlay_config) return base_config def detect_env() -> str: """Auto-detect environment from env vars. Checks: TIMMY_ENV, HERMES_ENV, ENVIRONMENT, NODE_ENV """ for var in ('TIMMY_ENV', 'HERMES_ENV', 'ENVIRONMENT', 'NODE_ENV'): val = os.environ.get(var, '').strip().lower() if val: return val return None def load_config_auto(base_path: str) -> dict: """Load config with auto-detected environment.""" env = detect_env() return load_config(base_path, env=env) def list_overlays(base_path: str) -> list: """List available overlay files for a base config.""" base = Path(base_path) pattern = f"{base.stem}.*{base.suffix}" overlays = [] for f in sorted(base.parent.glob(pattern)): if f == base: continue env_name = f.stem.replace(base.stem + '.', '') overlays.append({ 'env': env_name, 'path': str(f), }) return overlays if __name__ == '__main__': import sys import json base = sys.argv[1] if len(sys.argv) > 1 else 'config.yaml' env = sys.argv[2] if len(sys.argv) > 2 else None config = load_config(base, env=env) print(json.dumps(config, indent=2, default=str))