Compare commits
7 Commits
fix/622-to
...
burn/696-1
| Author | SHA1 | Date | |
|---|---|---|---|
| a186d35773 | |||
| 4475f82c0e | |||
| b25f5428b3 | |||
| e0f72ffee6 | |||
| d554e6396c | |||
| aa3672d08c | |||
| aae1a952f4 |
41
config/base.yaml
Normal file
41
config/base.yaml
Normal 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
24
config/cron.overlay.yaml
Normal 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
20
config/dev.overlay.yaml
Normal 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"
|
||||
20
config/gateway.overlay.yaml
Normal file
20
config/gateway.overlay.yaml
Normal 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
22
config/prod.overlay.yaml
Normal 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
211
scripts/config_template.py
Normal 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()
|
||||
133
tests/test_config_template.py
Normal file
133
tests/test_config_template.py
Normal 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
|
||||
Reference in New Issue
Block a user