Files
timmy-config/config_overlay.py
Alexander Whitestone 98b3b1eaed
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 26s
PR Checklist / pr-checklist (pull_request) Failing after 3m55s
Smoke Test / smoke (pull_request) Failing after 13s
Validate Config / YAML Lint (pull_request) Failing after 15s
Validate Config / JSON Validate (pull_request) Successful in 8s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 33s
Validate Config / Shell Script Lint (pull_request) Failing after 28s
Validate Config / Cron Syntax Check (pull_request) Successful in 6s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 5s
Validate Config / Playbook Schema Validation (pull_request) Successful in 10s
Architecture Lint / Lint Repository (pull_request) Has been cancelled
Validate Config / Python Test Suite (pull_request) Has been cancelled
feat(#696): Config template system — environment-specific overlays
Base config + env overlay merge system:
- config_overlay.py: deep_merge() for dict merging, load_config() for
  loading base + overlay, detect_env() for auto-detection
- config.dev.yaml: local dev (qwen3, verbose, reasoning shown)
- config.prod.yaml: production (claude-opus, compact, privacy)
- config.cron.yaml: headless/cron (no memory, minimal display)
- config.gateway.yaml: messaging gateway (balanced settings)
- tests/test_config_overlay.py: 13 tests, all passing

Usage:
  from config_overlay import load_config
  config = load_config('config.yaml', env='prod')

Overlay naming: config.{env}.yaml
Auto-detect: TIMMY_ENV, HERMES_ENV, ENVIRONMENT, NODE_ENV
2026-04-15 11:33:03 -04:00

131 lines
3.6 KiB
Python

#!/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))