feat(#696): Config template system — environment-specific overlays
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
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
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
This commit is contained in:
136
tests/test_config_overlay.py
Normal file
136
tests/test_config_overlay.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Tests for config overlay system."""
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
|
||||
def test_deep_merge_dicts():
|
||||
"""Deep merge should recursively merge dicts."""
|
||||
from config_overlay import deep_merge
|
||||
base = {"a": {"b": 1, "c": 2}, "d": 3}
|
||||
overlay = {"a": {"b": 10, "e": 5}}
|
||||
result = deep_merge(base, overlay)
|
||||
assert result == {"a": {"b": 10, "c": 2, "e": 5}, "d": 3}
|
||||
|
||||
|
||||
def test_deep_merge_lists_replaced():
|
||||
"""Lists should be replaced, not extended."""
|
||||
from config_overlay import deep_merge
|
||||
base = {"items": [1, 2, 3]}
|
||||
overlay = {"items": [4, 5]}
|
||||
result = deep_merge(base, overlay)
|
||||
assert result == {"items": [4, 5]}
|
||||
|
||||
|
||||
def test_deep_merge_scalars_overridden():
|
||||
"""Scalar values should be overridden by overlay."""
|
||||
from config_overlay import deep_merge
|
||||
base = {"name": "base", "count": 10}
|
||||
overlay = {"name": "override", "count": 20}
|
||||
result = deep_merge(base, overlay)
|
||||
assert result == {"name": "override", "count": 20}
|
||||
|
||||
|
||||
def test_deep_merge_none_removes_key():
|
||||
"""None in overlay should remove the key from base."""
|
||||
from config_overlay import deep_merge
|
||||
base = {"a": 1, "b": 2, "c": 3}
|
||||
overlay = {"b": None}
|
||||
result = deep_merge(base, overlay)
|
||||
assert result == {"a": 1, "c": 3}
|
||||
|
||||
|
||||
def test_deep_merge_empty_overlay():
|
||||
"""Empty overlay should return base unchanged."""
|
||||
from config_overlay import deep_merge
|
||||
base = {"a": 1, "b": {"c": 2}}
|
||||
result = deep_merge(base, {})
|
||||
assert result == base
|
||||
|
||||
|
||||
def test_load_config_no_env(tmp_path):
|
||||
"""Load config without overlay should return base."""
|
||||
from config_overlay import load_config
|
||||
base = {"model": "test", "agent": {"max_turns": 10}}
|
||||
path = tmp_path / "config.yaml"
|
||||
path.write_text(yaml.dump(base))
|
||||
result = load_config(str(path))
|
||||
assert result == base
|
||||
|
||||
|
||||
def test_load_config_with_overlay(tmp_path):
|
||||
"""Load config with overlay should merge."""
|
||||
from config_overlay import load_config
|
||||
base = {"model": "base", "agent": {"max_turns": 10, "verbose": False}}
|
||||
overlay = {"model": "override", "agent": {"verbose": True}}
|
||||
(tmp_path / "config.yaml").write_text(yaml.dump(base))
|
||||
(tmp_path / "config.dev.yaml").write_text(yaml.dump(overlay))
|
||||
result = load_config(str(tmp_path / "config.yaml"), env="dev")
|
||||
assert result["model"] == "override"
|
||||
assert result["agent"]["max_turns"] == 10
|
||||
assert result["agent"]["verbose"] is True
|
||||
|
||||
|
||||
def test_load_config_missing_overlay(tmp_path):
|
||||
"""Missing overlay should silently return base."""
|
||||
from config_overlay import load_config
|
||||
base = {"model": "base"}
|
||||
(tmp_path / "config.yaml").write_text(yaml.dump(base))
|
||||
result = load_config(str(tmp_path / "config.yaml"), env="nonexistent")
|
||||
assert result == base
|
||||
|
||||
|
||||
def test_find_config(tmp_path):
|
||||
"""find_config should locate base and overlay."""
|
||||
from config_overlay import find_config
|
||||
base = tmp_path / "config.yaml"
|
||||
base.write_text("a: 1")
|
||||
overlay = tmp_path / "config.prod.yaml"
|
||||
overlay.write_text("a: 2")
|
||||
b, o = find_config(str(base), "prod")
|
||||
assert b == base
|
||||
assert o == overlay
|
||||
|
||||
|
||||
def test_list_overlays(tmp_path):
|
||||
"""list_overlays should find all overlay files."""
|
||||
from config_overlay import list_overlays
|
||||
(tmp_path / "config.yaml").write_text("a: 1")
|
||||
(tmp_path / "config.dev.yaml").write_text("a: 2")
|
||||
(tmp_path / "config.prod.yaml").write_text("a: 3")
|
||||
overlays = list_overlays(str(tmp_path / "config.yaml"))
|
||||
envs = [o['env'] for o in overlays]
|
||||
assert 'dev' in envs
|
||||
assert 'prod' in envs
|
||||
|
||||
|
||||
def test_detect_env_from_var(tmp_path, monkeypatch):
|
||||
"""detect_env should check TIMMY_ENV first."""
|
||||
from config_overlay import detect_env
|
||||
monkeypatch.setenv("TIMMY_ENV", "prod")
|
||||
assert detect_env() == "prod"
|
||||
|
||||
|
||||
def test_detect_env_fallback(tmp_path, monkeypatch):
|
||||
"""detect_env should fall back through vars."""
|
||||
from config_overlay import detect_env
|
||||
monkeypatch.delenv("TIMMY_ENV", raising=False)
|
||||
monkeypatch.delenv("HERMES_ENV", raising=False)
|
||||
monkeypatch.setenv("ENVIRONMENT", "cron")
|
||||
assert detect_env() == "cron"
|
||||
|
||||
|
||||
def test_real_config_overlay():
|
||||
"""Test against actual config files in the repo."""
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from config_overlay import load_config
|
||||
|
||||
base_path = os.path.join(os.path.dirname(__file__), '..', 'config.yaml')
|
||||
if os.path.exists(base_path):
|
||||
config = load_config(base_path, env='dev')
|
||||
assert config['model']['default'] == 'qwen3:30b' # dev overrides
|
||||
assert config['agent']['max_turns'] == 50 # dev overrides
|
||||
assert 'terminal' in config # base keys preserved
|
||||
Reference in New Issue
Block a user