Compare commits
1 Commits
fix/681-sh
...
burn/696-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b3b1eaed |
19
config.cron.yaml
Normal file
19
config.cron.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Cron/headless environment overlay
|
||||
# For scheduled jobs and autonomous agents
|
||||
model:
|
||||
default: qwen3:30b
|
||||
provider: custom
|
||||
agent:
|
||||
max_turns: 100
|
||||
verbose: false
|
||||
display:
|
||||
compact: true
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
resume_display: minimal
|
||||
terminal:
|
||||
timeout: 300
|
||||
persistent_shell: true
|
||||
memory:
|
||||
memory_enabled: false
|
||||
user_profile_enabled: false
|
||||
14
config.dev.yaml
Normal file
14
config.dev.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Dev environment overlay
|
||||
# Merges with config.yaml — these keys override the base
|
||||
model:
|
||||
default: qwen3:30b
|
||||
provider: custom
|
||||
agent:
|
||||
max_turns: 50
|
||||
verbose: true
|
||||
display:
|
||||
show_reasoning: true
|
||||
streaming: true
|
||||
show_cost: true
|
||||
terminal:
|
||||
timeout: 300
|
||||
17
config.gateway.yaml
Normal file
17
config.gateway.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Gateway environment overlay
|
||||
# For messaging platform gateway (Telegram, Discord, etc.)
|
||||
model:
|
||||
default: qwen3:30b
|
||||
provider: custom
|
||||
agent:
|
||||
max_turns: 30
|
||||
verbose: false
|
||||
display:
|
||||
compact: true
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
terminal:
|
||||
timeout: 120
|
||||
memory:
|
||||
memory_enabled: true
|
||||
user_profile_enabled: true
|
||||
17
config.prod.yaml
Normal file
17
config.prod.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Prod environment overlay
|
||||
# Merges with config.yaml — these keys override the base
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
provider: anthropic
|
||||
agent:
|
||||
max_turns: 90
|
||||
verbose: false
|
||||
display:
|
||||
compact: true
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
show_cost: false
|
||||
privacy:
|
||||
redact_pii: true
|
||||
security:
|
||||
redact_secrets: true
|
||||
130
config_overlay.py
Normal file
130
config_overlay.py
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/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))
|
||||
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