Skip to content

Commit 91fdadd

Browse files
committed
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
1 parent 65cbbe8 commit 91fdadd

2 files changed

Lines changed: 202 additions & 2 deletions

File tree

openevolve/config.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import re
7+
import math
78
from dataclasses import asdict, dataclass, field
89
from pathlib import Path
910
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
@@ -435,8 +436,11 @@ def from_yaml(cls, path: Union[str, Path]) -> "Config":
435436
"""Load configuration from a YAML file"""
436437
config_path = Path(path).resolve()
437438
with open(config_path, "r") as f:
438-
config_dict = yaml.safe_load(f)
439-
config = cls.from_dict(config_dict)
439+
config_content = f.read()
440+
441+
# Render placeholders in content
442+
rendered_dict = render_config_dict(config_content)
443+
config = cls.from_dict(rendered_dict)
440444

441445
# Resolve template_dir relative to config file location
442446
if config.prompt.template_dir:
@@ -491,6 +495,122 @@ def to_yaml(self, path: Union[str, Path]) -> None:
491495
yaml.dump(self.to_dict(), f, default_flow_style=False)
492496

493497

498+
class ConfigContext:
499+
"""Helper class to allow dot notation access to nested dictionaries in eval()"""
500+
def __init__(self, data):
501+
self._data = data
502+
503+
def __getattr__(self, name):
504+
if name in self._data:
505+
val = self._data[name]
506+
if isinstance(val, dict):
507+
return ConfigContext(val)
508+
return val
509+
raise AttributeError(f"ConfigContext has no attribute '{name}'")
510+
511+
def __getitem__(self, key):
512+
return self._data[key]
513+
514+
def render_config_dict(config_content: str) -> Dict[str, Any]:
515+
"""
516+
Render placeholders in the config content and return the resulting dictionary.
517+
Supports:
518+
- {{key}} or {{parent.child}} syntax
519+
- f"{expression}" syntax for complex math and variable references
520+
"""
521+
# Try to load for value lookup
522+
try:
523+
config_data = yaml.safe_load(config_content) or {}
524+
except yaml.YAMLError:
525+
# If load fails due to placeholders, try loading without those lines to get context
526+
temp_lines = [
527+
line
528+
for line in config_content.splitlines()
529+
if "{{" not in line and 'f"' not in line
530+
]
531+
try:
532+
config_data = yaml.safe_load("\n".join(temp_lines)) or {}
533+
except yaml.YAMLError:
534+
config_data = {}
535+
536+
# 1. Handle legacy {{key}} placeholders
537+
def legacy_replacer(match):
538+
full_match = match.group(0)
539+
placeholder = match.group(2)
540+
has_quotes = full_match.startswith('"') or full_match.startswith("'")
541+
542+
keys = placeholder.split(".")
543+
value = config_data
544+
try:
545+
for key in keys:
546+
value = value[key]
547+
548+
if isinstance(value, (int, float, bool)) and has_quotes:
549+
return str(value)
550+
return str(value)
551+
except (KeyError, TypeError):
552+
return full_match
553+
554+
rendered_content = re.sub(
555+
r"([\"']?)\{\{([\w\.]+)\}\}\1", legacy_replacer, config_content
556+
)
557+
558+
# 2. Handle f"{expression}" syntax
559+
# We need to re-parse the partially rendered content to handle f-strings in a tree-like manner
560+
try:
561+
config_tree = yaml.safe_load(rendered_content) or {}
562+
except yaml.YAMLError:
563+
config_tree = config_data
564+
565+
def evaluate_f_strings(node, context_root):
566+
if isinstance(node, dict):
567+
return {k: evaluate_f_strings(v, context_root) for k, v in node.items()}
568+
elif isinstance(node, list):
569+
return [evaluate_f_strings(i, context_root) for i in node]
570+
elif isinstance(node, str) and node.startswith('f"') and node.endswith('"'):
571+
content = node[2:-1]
572+
573+
# Check if the entire content is a single expression like f"{...}"
574+
# If so, we want to return the actual type (int, float, etc.)
575+
if (
576+
content.startswith("{")
577+
and content.endswith("}")
578+
and content.count("{") == 1
579+
):
580+
expr = content[1:-1]
581+
try:
582+
safe_ns = {"math": math, "__builtins__": {}}
583+
for k, v in context_root.items():
584+
if isinstance(v, dict):
585+
safe_ns[k] = ConfigContext(v)
586+
else:
587+
safe_ns[k] = v
588+
return eval(expr, safe_ns)
589+
except Exception as e:
590+
return f"<Error: {e}>"
591+
592+
# Mixed content or multiple expressions: return as string
593+
def f_replacer(match):
594+
expr = match.group(1)
595+
try:
596+
safe_ns = {"math": math, "__builtins__": {}}
597+
for k, v in context_root.items():
598+
if isinstance(v, dict):
599+
safe_ns[k] = ConfigContext(v)
600+
else:
601+
safe_ns[k] = v
602+
603+
result = eval(expr, safe_ns)
604+
return str(result)
605+
except Exception as e:
606+
return f"<Error: {e}>"
607+
608+
return re.sub(r"\{([^}]+)\}", f_replacer, content)
609+
return node
610+
611+
return evaluate_f_strings(config_tree, config_tree)
612+
613+
494614
def load_config(config_path: Optional[Union[str, Path]] = None) -> Config:
495615
"""Load configuration from a YAML file or use defaults"""
496616
if config_path and os.path.exists(config_path):

tests/test_config_render.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import unittest
2+
import os
3+
import yaml
4+
import tempfile
5+
from pathlib import Path
6+
from openevolve.config import render_config_dict, load_config
7+
8+
class TestConfigRender(unittest.TestCase):
9+
def setUp(self):
10+
self.temp_dir = tempfile.TemporaryDirectory()
11+
self.config_path = Path(self.temp_dir.name) / "test_config.yaml"
12+
13+
def tearDown(self):
14+
self.temp_dir.cleanup()
15+
16+
def test_legacy_rendering(self):
17+
config_content = """
18+
base_val: 10
19+
derived: "{{base_val}}"
20+
nested:
21+
child: 5
22+
ref: "{{nested.child}}"
23+
"""
24+
data = render_config_dict(config_content)
25+
26+
self.assertEqual(data['derived'], 10)
27+
self.assertEqual(data['nested']['ref'], 5)
28+
29+
def test_f_string_simple_expression(self):
30+
config_content = """
31+
val: 100
32+
expr: 'f"{val * 2}"'
33+
"""
34+
data = render_config_dict(config_content)
35+
36+
self.assertEqual(data['expr'], 200)
37+
38+
def test_f_string_math_integration(self):
39+
config_content = """
40+
val: 16
41+
sqrt_val: 'f"{math.sqrt(val)}"'
42+
"""
43+
data = render_config_dict(config_content)
44+
45+
self.assertEqual(data['sqrt_val'], 4.0)
46+
47+
def test_f_string_nested_context(self):
48+
config_content = """
49+
database:
50+
num_islands: 4
51+
batch_size: 10
52+
total_parallel: 'f"{database.num_islands * database.batch_size}"'
53+
"""
54+
data = render_config_dict(config_content)
55+
56+
self.assertEqual(data['total_parallel'], 40)
57+
58+
def test_f_string_mixed_content(self):
59+
config_content = """
60+
name: "Evolve"
61+
msg: 'f"Hello {name}!"'
62+
"""
63+
data = render_config_dict(config_content)
64+
65+
self.assertEqual(data['msg'], "Hello Evolve!")
66+
67+
def test_load_config_integration(self):
68+
config_content = """
69+
llm:
70+
temperature: 'f"{0.5 + 0.2}"'
71+
max_iterations: 'f"{10 * 100}"'
72+
"""
73+
self.config_path.write_text(config_content)
74+
config = load_config(self.config_path)
75+
76+
self.assertAlmostEqual(config.llm.temperature, 0.7)
77+
self.assertEqual(config.max_iterations, 1000)
78+
79+
if __name__ == "__main__":
80+
unittest.main()

0 commit comments

Comments
 (0)