diff --git a/tests/unit/test_config_merger.py b/tests/unit/test_config_merger.py index 8956cd7..3580a5d 100644 --- a/tests/unit/test_config_merger.py +++ b/tests/unit/test_config_merger.py @@ -174,3 +174,120 @@ def test_validate_config_end_before_init(): with pytest.raises(ConfigValidationError, match="init_date must be <= end_date"): validate_config(config) + + +import os +from tools.config_merger import merge_and_validate + + +def test_merge_and_validate_success(tmp_path, monkeypatch): + """Test successful merge and validation""" + # Create default config + default_config = { + "agent_type": "BaseAgent", + "models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}], + "agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0}, + "log_config": {"log_path": "./data"} + } + + default_path = tmp_path / "default_config.json" + with open(default_path, 'w') as f: + json.dump(default_config, f) + + # Create custom config (only overrides models) + custom_config = { + "models": [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": True}] + } + + custom_path = tmp_path / "config.json" + with open(custom_path, 'w') as f: + json.dump(custom_config, f) + + output_path = tmp_path / "runtime_config.json" + + # Mock file paths + monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path)) + monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path)) + monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path)) + + # Run merge and validate + merge_and_validate() + + # Verify output file was created + assert output_path.exists() + + # Verify merged content + with open(output_path, 'r') as f: + result = json.load(f) + + assert result["models"] == [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": True}] + assert result["agent_config"] == {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0} + + +def test_merge_and_validate_no_custom_config(tmp_path, monkeypatch): + """Test when no custom config exists (uses default only)""" + default_config = { + "agent_type": "BaseAgent", + "models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}], + "agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0}, + "log_config": {"log_path": "./data"} + } + + default_path = tmp_path / "default_config.json" + with open(default_path, 'w') as f: + json.dump(default_config, f) + + custom_path = tmp_path / "config.json" # Does not exist + output_path = tmp_path / "runtime_config.json" + + monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path)) + monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path)) + monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path)) + + merge_and_validate() + + # Verify output matches default + with open(output_path, 'r') as f: + result = json.load(f) + + assert result == default_config + + +def test_merge_and_validate_validation_fails(tmp_path, monkeypatch, capsys): + """Test validation failure exits with error""" + default_config = { + "agent_type": "BaseAgent", + "models": [{"name": "default", "basemodel": "openai/gpt-4", "signature": "default", "enabled": True}], + "agent_config": {"max_steps": 30, "max_retries": 3, "initial_cash": 10000.0}, + "log_config": {"log_path": "./data"} + } + + default_path = tmp_path / "default_config.json" + with open(default_path, 'w') as f: + json.dump(default_config, f) + + # Custom config with no enabled models + custom_config = { + "models": [{"name": "custom", "basemodel": "openai/gpt-5", "signature": "custom", "enabled": False}] + } + + custom_path = tmp_path / "config.json" + with open(custom_path, 'w') as f: + json.dump(custom_config, f) + + output_path = tmp_path / "runtime_config.json" + + monkeypatch.setattr("tools.config_merger.DEFAULT_CONFIG_PATH", str(default_path)) + monkeypatch.setattr("tools.config_merger.CUSTOM_CONFIG_PATH", str(custom_path)) + monkeypatch.setattr("tools.config_merger.OUTPUT_CONFIG_PATH", str(output_path)) + + # Should exit with error + with pytest.raises(SystemExit) as exc_info: + merge_and_validate() + + assert exc_info.value.code == 1 + + # Check error output (should be in stderr, not stdout) + captured = capsys.readouterr() + assert "CONFIG VALIDATION FAILED" in captured.err + assert "At least one model must be enabled" in captured.err diff --git a/tools/config_merger.py b/tools/config_merger.py index 2d692e2..3b29caf 100644 --- a/tools/config_merger.py +++ b/tools/config_merger.py @@ -145,3 +145,84 @@ def validate_config(config: Dict[str, Any]) -> None: raise ConfigValidationError( f"init_date must be <= end_date (got {date_range['init_date']} > {date_range['end_date']})" ) + + +# File path constants (can be overridden for testing) +DEFAULT_CONFIG_PATH = "configs/default_config.json" +CUSTOM_CONFIG_PATH = "user-configs/config.json" +OUTPUT_CONFIG_PATH = "/tmp/runtime_config.json" + + +def format_error_message(error: str, location: str, file: str) -> str: + """Format validation error for display.""" + border = "━" * 60 + return f""" +❌ CONFIG VALIDATION FAILED +{border} + +Error: {error} +Location: {location} +File: {file} + +Merged config written to: {OUTPUT_CONFIG_PATH} (for debugging) + +Container will exit. Fix config and restart. +""" + + +def merge_and_validate() -> None: + """ + Main entry point for config merging and validation. + + Loads default config, optionally merges custom config, + validates the result, and writes to output path. + + Exits with code 1 on any error. + """ + try: + # Load default config + print(f"📄 Loading default config from {DEFAULT_CONFIG_PATH}") + default_config = load_config(DEFAULT_CONFIG_PATH) + + # Load custom config if exists + custom_config = {} + if Path(CUSTOM_CONFIG_PATH).exists(): + print(f"📝 Loading custom config from {CUSTOM_CONFIG_PATH}") + custom_config = load_config(CUSTOM_CONFIG_PATH) + else: + print(f"â„šī¸ No custom config found at {CUSTOM_CONFIG_PATH}, using defaults") + + # Merge configs + print("🔧 Merging configurations...") + merged_config = merge_configs(default_config, custom_config) + + # Write merged config (for debugging even if validation fails) + output_path = Path(OUTPUT_CONFIG_PATH) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, 'w') as f: + json.dump(merged_config, f, indent=2) + + # Validate merged config + print("✅ Validating merged configuration...") + validate_config(merged_config) + + print(f"✅ Configuration validated successfully") + print(f"đŸ“Ļ Merged config written to {OUTPUT_CONFIG_PATH}") + + except ConfigValidationError as e: + # Determine which file caused the error + error_file = CUSTOM_CONFIG_PATH if Path(CUSTOM_CONFIG_PATH).exists() else DEFAULT_CONFIG_PATH + + error_msg = format_error_message( + error=str(e), + location="Root level", + file=error_file + ) + + print(error_msg, file=sys.stderr) + sys.exit(1) + + except Exception as e: + print(f"❌ Unexpected error during config processing: {e}", file=sys.stderr) + sys.exit(1)