Compare commits

...

7 Commits

Author SHA1 Message Date
a186d35773 test: Add config template test suite (#696)
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 24s
Smoke Test / smoke (pull_request) Failing after 20s
Validate Config / YAML Lint (pull_request) Failing after 13s
Validate Config / JSON Validate (pull_request) Successful in 13s
PR Checklist / pr-checklist (pull_request) Failing after 3m49s
Validate Config / Shell Script Lint (pull_request) Failing after 48s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 1m19s
Validate Config / Cron Syntax Check (pull_request) Successful in 8s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 7s
Validate Config / Playbook Schema Validation (pull_request) Successful in 18s
Architecture Lint / Lint Repository (pull_request) Has been cancelled
Validate Config / Python Test Suite (pull_request) Has been cancelled
2026-04-15 14:57:10 +00:00
4475f82c0e feat: Add gateway overlay (#696) 2026-04-15 14:55:15 +00:00
b25f5428b3 feat: Add cron overlay (#696) 2026-04-15 14:55:12 +00:00
e0f72ffee6 feat: Add prod overlay (#696) 2026-04-15 14:55:09 +00:00
d554e6396c feat: Add dev overlay (#696) 2026-04-15 14:55:07 +00:00
aa3672d08c feat: Add base config template (#696) 2026-04-15 14:55:04 +00:00
aae1a952f4 feat: Add config template system with environment overlays (#696) 2026-04-15 14:55:01 +00:00
7 changed files with 471 additions and 0 deletions

41
config/base.yaml Normal file
View File

@@ -0,0 +1,41 @@
# Base config — shared defaults across all environments
# Overridden by {env}.overlay.yaml on merge
model:
name: "nousresearch/hermes-4-14b"
provider: "openrouter"
temperature: 0.7
max_tokens: 4096
provider:
name: "openrouter"
base_url: "https://openrouter.ai/api/v1"
cron:
enabled: false
interval_seconds: 300
max_concurrent: 3
gateway:
enabled: false
cors_origins: []
port: 8080
display:
spinner: true
colors: true
tool_progress: true
tools:
enabled: true
browser: true
web_search: true
session:
save_trajectories: false
max_iterations: 90
context_compression: true
logging:
level: "INFO"
file: null

24
config/cron.overlay.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Cron/headless environment overlay
# Deterministic, no display
model:
temperature: 0.1
max_tokens: 4096
cron:
enabled: true
interval_seconds: 120
max_concurrent: 8
display:
spinner: false
colors: false
tool_progress: false
session:
save_trajectories: false
max_iterations: 60
logging:
level: "INFO"
file: "/var/log/timmy/cron.log"

20
config/dev.overlay.yaml Normal file
View File

@@ -0,0 +1,20 @@
# Dev environment overlay
# Higher verbosity, faster iteration
model:
temperature: 0.9
max_tokens: 2048
cron:
interval_seconds: 60
max_concurrent: 1
display:
tool_progress: true
session:
save_trajectories: true
max_iterations: 30
logging:
level: "DEBUG"

View File

@@ -0,0 +1,20 @@
# Gateway environment overlay
# Multi-platform messaging, no cron
model:
temperature: 0.5
cron:
enabled: false
gateway:
enabled: true
cors_origins: ["*"]
port: 8080
session:
save_trajectories: true
max_iterations: 50
logging:
level: "INFO"

22
config/prod.overlay.yaml Normal file
View File

@@ -0,0 +1,22 @@
# Prod environment overlay
# Lower temperature, stable settings
model:
temperature: 0.3
max_tokens: 4096
cron:
enabled: true
interval_seconds: 600
max_concurrent: 5
gateway:
enabled: true
port: 8080
session:
save_trajectories: false
max_iterations: 120
logging:
level: "WARNING"

211
scripts/config_template.py Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Config Template System — Environment-Specific Overlays (Issue #696)
Loads base.yaml + {env}.overlay.yaml with deep merge.
Overlay keys override base keys. Supports dot notation access.
Usage:
from scripts.config_template import ConfigTemplate, load_config
config = load_config("dev")
template = ConfigTemplate()
template.load("prod")
model = template.get("model.name")
CLI:
python3 scripts/config_template.py --env prod
python3 scripts/config_template.py --env dev --diff
python3 scripts/config_template.py --env prod --validate
python3 scripts/config_template.py --list-envs
"""
import argparse
import copy
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
import yaml
except ImportError:
yaml = None
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
KNOWN_ENVS = ("dev", "prod", "cron", "gateway")
def _deep_merge(base: dict, overlay: dict) -> dict:
"""Deep merge overlay into base. Overlay values win on conflict."""
result = copy.deepcopy(base)
for key, value in overlay.items():
if 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 _get_dotted(data: dict, key: str, default: Any = None) -> Any:
"""Get value from dict using dot notation: 'model.name' -> data['model']['name']."""
parts = key.split(".")
current = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return default
return current
def _diff_dicts(base: dict, overlay: dict, prefix: str = "") -> List[dict]:
"""Compute diff between base and overlay configs."""
diffs = []
all_keys = set(list(base.keys()) + list(overlay.keys()))
for key in sorted(all_keys):
path = f"{prefix}.{key}" if prefix else key
in_base = key in base
in_overlay = key in overlay
if in_base and not in_overlay:
diffs.append({"key": path, "type": "removed_in_overlay", "base": base[key]})
elif not in_base and in_overlay:
diffs.append({"key": path, "type": "added_in_overlay", "overlay": overlay[key]})
elif isinstance(base[key], dict) and isinstance(overlay[key], dict):
diffs.extend(_diff_dicts(base[key], overlay[key], path))
elif base[key] != overlay[key]:
diffs.append({
"key": path, "type": "changed",
"base": base[key], "overlay": overlay[key]
})
return diffs
def _validate_config(config: dict) -> List[str]:
"""Validate config structure, return list of warnings."""
warnings = []
if "model" not in config:
warnings.append("Missing 'model' section")
elif "name" not in config.get("model", {}):
warnings.append("Missing 'model.name'")
if "provider" not in config:
warnings.append("Missing 'provider' section")
for key in config:
if not isinstance(key, str):
warnings.append(f"Non-string key: {key!r}")
return warnings
def _load_yaml_file(path: Path) -> dict:
"""Load a YAML file, return empty dict if missing."""
if not path.exists():
return {}
if yaml is None:
raise ImportError("PyYAML required: pip install pyyaml")
with open(path) as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else {}
class ConfigTemplate:
"""Environment-specific config template with overlay merge."""
def __init__(self, config_dir: Optional[str] = None):
self.config_dir = Path(config_dir) if config_dir else CONFIG_DIR
self.base: Dict[str, Any] = {}
self.overlay: Dict[str, Any] = {}
self.merged: Dict[str, Any] = {}
self.env: Optional[str] = None
def load(self, env: str) -> dict:
"""Load base + overlay for the given environment."""
if env not in KNOWN_ENVS:
raise ValueError(f"Unknown environment '{env}'. Known: {', '.join(KNOWN_ENVS)}")
self.env = env
self.base = _load_yaml_file(self.config_dir / "base.yaml")
self.overlay = _load_yaml_file(self.config_dir / f"{env}.overlay.yaml")
self.merged = _deep_merge(self.base, self.overlay)
return self.merged
def get(self, key: str, default: Any = None) -> Any:
"""Get value with dot notation from merged config."""
return _get_dotted(self.merged, key, default)
def diff(self) -> List[dict]:
"""Show diff between base and current overlay."""
return _diff_dicts(self.base, self.overlay)
def validate(self) -> List[str]:
"""Validate merged config structure."""
return _validate_config(self.merged)
@staticmethod
def list_environments() -> List[str]:
"""List known environments."""
return list(KNOWN_ENVS)
def load_config(env: str, config_dir: Optional[str] = None) -> dict:
"""One-shot: load merged config for an environment."""
t = ConfigTemplate(config_dir)
return t.load(env)
def main():
parser = argparse.ArgumentParser(description="Config Template System")
parser.add_argument("--env", default="dev", help="Environment name")
parser.add_argument("--diff", action="store_true", help="Show diff between base and overlay")
parser.add_argument("--validate", action="store_true", help="Validate merged config")
parser.add_argument("--list-envs", action="store_true", help="List known environments")
parser.add_argument("--config-dir", default=None, help="Config directory path")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.list_envs:
envs = ConfigTemplate.list_environments()
if args.json:
print(json.dumps(envs, indent=2))
else:
for e in envs:
print(f" {e}")
return
template = ConfigTemplate(args.config_dir)
template.load(args.env)
if args.diff:
diffs = template.diff()
if args.json:
print(json.dumps(diffs, indent=2))
else:
if not diffs:
print(f"No differences between base and {args.env} overlay")
for d in diffs:
if d["type"] == "changed":
print(f" {d['key']}: {d['base']!r} -> {d['overlay']!r}")
elif d["type"] == "added_in_overlay":
print(f" {d['key']}: + {d['overlay']!r}")
elif d["type"] == "removed_in_overlay":
print(f" {d['key']}: - {d['base']!r}")
elif args.validate:
warnings = template.validate()
if args.json:
print(json.dumps({"valid": len(warnings) == 0, "warnings": warnings}, indent=2))
else:
if warnings:
for w in warnings:
print(f" WARNING: {w}")
else:
print(f" Config valid for {args.env}")
else:
if args.json:
print(json.dumps(template.merged, indent=2))
else:
print(f"Config for {args.env}:")
for k, v in template.merged.items():
print(f" {k}: {v!r}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Tests for config_template.py — issue #696"""
import os
import sys
import tempfile
import pytest
from pathlib import Path
# Add parent dir for import
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from config_template import ConfigTemplate, _deep_merge, _get_dotted, _diff_dicts, _validate_config
@pytest.fixture
def tmp_config_dir(tmp_path):
"""Create a temp config dir with base + overlay files."""
import yaml
base = {
"model": {"name": "base-model", "temperature": 0.7},
"cron": {"enabled": False, "interval": 300},
"display": {"colors": True},
}
overlay = {
"model": {"temperature": 0.9},
"cron": {"enabled": True},
"logging": {"level": "DEBUG"},
}
with open(tmp_path / "base.yaml", "w") as f:
yaml.dump(base, f)
with open(tmp_path / "test.overlay.yaml", "w") as f:
yaml.dump(overlay, f)
return tmp_path
class TestDeepMerge:
def test_overlay_wins(self):
base = {"a": 1, "b": 2}
overlay = {"b": 99}
result = _deep_merge(base, overlay)
assert result == {"a": 1, "b": 99}
def test_deep_merge_nested(self):
base = {"model": {"name": "x", "temp": 0.7}}
overlay = {"model": {"temp": 0.9}}
result = _deep_merge(base, overlay)
assert result["model"]["name"] == "x"
assert result["model"]["temp"] == 0.9
def test_new_keys_added(self):
base = {"a": 1}
overlay = {"b": 2}
result = _deep_merge(base, overlay)
assert result == {"a": 1, "b": 2}
def test_originals_unchanged(self):
base = {"a": {"inner": 1}}
overlay = {"a": {"inner": 99}}
_deep_merge(base, overlay)
assert base["a"]["inner"] == 1
class TestDottedAccess:
def test_simple_key(self):
assert _get_dotted({"a": 1}, "a") == 1
def test_nested_key(self):
assert _get_dotted({"a": {"b": {"c": 42}}}, "a.b.c") == 42
def test_missing_key_returns_default(self):
assert _get_dotted({"a": 1}, "x", "fallback") == "fallback"
def test_partial_path(self):
assert _get_dotted({"a": 1}, "a.b.c", None) is None
class TestDiff:
def test_no_diff(self):
assert _diff_dicts({"a": 1}, {"a": 1}) == []
def test_changed_value(self):
diffs = _diff_dicts({"a": 1}, {"a": 2})
assert len(diffs) == 1
assert diffs[0]["type"] == "changed"
def test_added_key(self):
diffs = _diff_dicts({"a": 1}, {"a": 1, "b": 2})
added = [d for d in diffs if d["type"] == "added_in_overlay"]
assert len(added) == 1
assert added[0]["key"] == "b"
class TestValidation:
def test_valid_config(self):
config = {"model": {"name": "x"}, "provider": {"name": "y"}}
assert _validate_config(config) == []
def test_missing_model(self):
warnings = _validate_config({"provider": {}})
assert any("model" in w for w in warnings)
class TestConfigTemplate:
def test_load(self, tmp_config_dir):
t = ConfigTemplate(str(tmp_config_dir))
merged = t.load("test")
assert merged["model"]["name"] == "base-model"
assert merged["model"]["temperature"] == 0.9
assert merged["cron"]["enabled"] is True
assert merged["logging"]["level"] == "DEBUG"
def test_get_dotted(self, tmp_config_dir):
t = ConfigTemplate(str(tmp_config_dir))
t.load("test")
assert t.get("model.temperature") == 0.9
assert t.get("nonexistent", "default") == "default"
def test_diff(self, tmp_config_dir):
t = ConfigTemplate(str(tmp_config_dir))
t.load("test")
diffs = t.diff()
assert len(diffs) > 0
def test_unknown_env_raises(self, tmp_config_dir):
t = ConfigTemplate(str(tmp_config_dir))
with pytest.raises(ValueError, match="Unknown environment"):
t.load("nonexistent")
def test_list_environments(self):
envs = ConfigTemplate.list_environments()
assert "dev" in envs
assert "prod" in envs
assert "cron" in envs
assert "gateway" in envs