|
4 | 4 |
|
5 | 5 | import os |
6 | 6 | import re |
| 7 | +import math |
7 | 8 | from dataclasses import asdict, dataclass, field |
8 | 9 | from pathlib import Path |
9 | 10 | from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union |
@@ -435,8 +436,11 @@ def from_yaml(cls, path: Union[str, Path]) -> "Config": |
435 | 436 | """Load configuration from a YAML file""" |
436 | 437 | config_path = Path(path).resolve() |
437 | 438 | 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) |
440 | 444 |
|
441 | 445 | # Resolve template_dir relative to config file location |
442 | 446 | if config.prompt.template_dir: |
@@ -491,6 +495,122 @@ def to_yaml(self, path: Union[str, Path]) -> None: |
491 | 495 | yaml.dump(self.to_dict(), f, default_flow_style=False) |
492 | 496 |
|
493 | 497 |
|
| 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 | + |
494 | 614 | def load_config(config_path: Optional[Union[str, Path]] = None) -> Config: |
495 | 615 | """Load configuration from a YAML file or use defaults""" |
496 | 616 | if config_path and os.path.exists(config_path): |
|
0 commit comments