From 91fdadd7ad9e3f0a11d043482921086b803e36f2 Mon Sep 17 00:00:00 2001 From: mensriwq <60219182+mensriwq@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:30:52 +0800 Subject: [PATCH] feat(config): implement dynamic config rendering with f-string support - Add `render_config_dict` in openevolve/config.py to resolve placeholders - Support `{{key}}` syntax and `f"{expression}"` syntax - Integrate `math` module and dot-notation access in config expressions - Automatically render configurations during `load_config` - Add unit tests for dynamic rendering in tests/test_config_render.py --- openevolve/config.py | 124 +++++++++++++++++++++++++++++++++++- tests/test_config_render.py | 80 +++++++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 tests/test_config_render.py diff --git a/openevolve/config.py b/openevolve/config.py index bef193da21..93692fd365 100644 --- a/openevolve/config.py +++ b/openevolve/config.py @@ -4,6 +4,7 @@ import os import re +import math from dataclasses import asdict, dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union @@ -435,8 +436,11 @@ def from_yaml(cls, path: Union[str, Path]) -> "Config": """Load configuration from a YAML file""" config_path = Path(path).resolve() with open(config_path, "r") as f: - config_dict = yaml.safe_load(f) - config = cls.from_dict(config_dict) + config_content = f.read() + + # Render placeholders in content + rendered_dict = render_config_dict(config_content) + config = cls.from_dict(rendered_dict) # Resolve template_dir relative to config file location if config.prompt.template_dir: @@ -491,6 +495,122 @@ def to_yaml(self, path: Union[str, Path]) -> None: yaml.dump(self.to_dict(), f, default_flow_style=False) +class ConfigContext: + """Helper class to allow dot notation access to nested dictionaries in eval()""" + def __init__(self, data): + self._data = data + + def __getattr__(self, name): + if name in self._data: + val = self._data[name] + if isinstance(val, dict): + return ConfigContext(val) + return val + raise AttributeError(f"ConfigContext has no attribute '{name}'") + + def __getitem__(self, key): + return self._data[key] + +def render_config_dict(config_content: str) -> Dict[str, Any]: + """ + Render placeholders in the config content and return the resulting dictionary. + Supports: + - {{key}} or {{parent.child}} syntax + - f"{expression}" syntax for complex math and variable references + """ + # Try to load for value lookup + try: + config_data = yaml.safe_load(config_content) or {} + except yaml.YAMLError: + # If load fails due to placeholders, try loading without those lines to get context + temp_lines = [ + line + for line in config_content.splitlines() + if "{{" not in line and 'f"' not in line + ] + try: + config_data = yaml.safe_load("\n".join(temp_lines)) or {} + except yaml.YAMLError: + config_data = {} + + # 1. Handle legacy {{key}} placeholders + def legacy_replacer(match): + full_match = match.group(0) + placeholder = match.group(2) + has_quotes = full_match.startswith('"') or full_match.startswith("'") + + keys = placeholder.split(".") + value = config_data + try: + for key in keys: + value = value[key] + + if isinstance(value, (int, float, bool)) and has_quotes: + return str(value) + return str(value) + except (KeyError, TypeError): + return full_match + + rendered_content = re.sub( + r"([\"']?)\{\{([\w\.]+)\}\}\1", legacy_replacer, config_content + ) + + # 2. Handle f"{expression}" syntax + # We need to re-parse the partially rendered content to handle f-strings in a tree-like manner + try: + config_tree = yaml.safe_load(rendered_content) or {} + except yaml.YAMLError: + config_tree = config_data + + def evaluate_f_strings(node, context_root): + if isinstance(node, dict): + return {k: evaluate_f_strings(v, context_root) for k, v in node.items()} + elif isinstance(node, list): + return [evaluate_f_strings(i, context_root) for i in node] + elif isinstance(node, str) and node.startswith('f"') and node.endswith('"'): + content = node[2:-1] + + # Check if the entire content is a single expression like f"{...}" + # If so, we want to return the actual type (int, float, etc.) + if ( + content.startswith("{") + and content.endswith("}") + and content.count("{") == 1 + ): + expr = content[1:-1] + try: + safe_ns = {"math": math, "__builtins__": {}} + for k, v in context_root.items(): + if isinstance(v, dict): + safe_ns[k] = ConfigContext(v) + else: + safe_ns[k] = v + return eval(expr, safe_ns) + except Exception as e: + return f"" + + # Mixed content or multiple expressions: return as string + def f_replacer(match): + expr = match.group(1) + try: + safe_ns = {"math": math, "__builtins__": {}} + for k, v in context_root.items(): + if isinstance(v, dict): + safe_ns[k] = ConfigContext(v) + else: + safe_ns[k] = v + + result = eval(expr, safe_ns) + return str(result) + except Exception as e: + return f"" + + return re.sub(r"\{([^}]+)\}", f_replacer, content) + return node + + return evaluate_f_strings(config_tree, config_tree) + + def load_config(config_path: Optional[Union[str, Path]] = None) -> Config: """Load configuration from a YAML file or use defaults""" if config_path and os.path.exists(config_path): diff --git a/tests/test_config_render.py b/tests/test_config_render.py new file mode 100644 index 0000000000..64344f36b1 --- /dev/null +++ b/tests/test_config_render.py @@ -0,0 +1,80 @@ +import unittest +import os +import yaml +import tempfile +from pathlib import Path +from openevolve.config import render_config_dict, load_config + +class TestConfigRender(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.config_path = Path(self.temp_dir.name) / "test_config.yaml" + + def tearDown(self): + self.temp_dir.cleanup() + + def test_legacy_rendering(self): + config_content = """ +base_val: 10 +derived: "{{base_val}}" +nested: + child: 5 + ref: "{{nested.child}}" +""" + data = render_config_dict(config_content) + + self.assertEqual(data['derived'], 10) + self.assertEqual(data['nested']['ref'], 5) + + def test_f_string_simple_expression(self): + config_content = """ +val: 100 +expr: 'f"{val * 2}"' +""" + data = render_config_dict(config_content) + + self.assertEqual(data['expr'], 200) + + def test_f_string_math_integration(self): + config_content = """ +val: 16 +sqrt_val: 'f"{math.sqrt(val)}"' +""" + data = render_config_dict(config_content) + + self.assertEqual(data['sqrt_val'], 4.0) + + def test_f_string_nested_context(self): + config_content = """ +database: + num_islands: 4 + batch_size: 10 +total_parallel: 'f"{database.num_islands * database.batch_size}"' +""" + data = render_config_dict(config_content) + + self.assertEqual(data['total_parallel'], 40) + + def test_f_string_mixed_content(self): + config_content = """ +name: "Evolve" +msg: 'f"Hello {name}!"' +""" + data = render_config_dict(config_content) + + self.assertEqual(data['msg'], "Hello Evolve!") + + def test_load_config_integration(self): + config_content = """ +llm: + temperature: 'f"{0.5 + 0.2}"' +max_iterations: 'f"{10 * 100}"' +""" + self.config_path.write_text(config_content) + config = load_config(self.config_path) + + self.assertAlmostEqual(config.llm.temperature, 0.7) + self.assertEqual(config.max_iterations, 1000) + +if __name__ == "__main__": + unittest.main()