yamlgraph 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of yamlgraph might be problematic. Click here for more details.

Files changed (111) hide show
  1. examples/__init__.py +1 -0
  2. examples/storyboard/__init__.py +1 -0
  3. examples/storyboard/generate_videos.py +335 -0
  4. examples/storyboard/nodes/__init__.py +10 -0
  5. examples/storyboard/nodes/animated_character_node.py +248 -0
  6. examples/storyboard/nodes/animated_image_node.py +138 -0
  7. examples/storyboard/nodes/character_node.py +162 -0
  8. examples/storyboard/nodes/image_node.py +118 -0
  9. examples/storyboard/nodes/replicate_tool.py +238 -0
  10. examples/storyboard/retry_images.py +118 -0
  11. tests/__init__.py +1 -0
  12. tests/conftest.py +178 -0
  13. tests/integration/__init__.py +1 -0
  14. tests/integration/test_animated_storyboard.py +63 -0
  15. tests/integration/test_cli_commands.py +242 -0
  16. tests/integration/test_map_demo.py +50 -0
  17. tests/integration/test_memory_demo.py +281 -0
  18. tests/integration/test_pipeline_flow.py +105 -0
  19. tests/integration/test_providers.py +163 -0
  20. tests/integration/test_resume.py +75 -0
  21. tests/unit/__init__.py +1 -0
  22. tests/unit/test_agent_nodes.py +200 -0
  23. tests/unit/test_checkpointer.py +212 -0
  24. tests/unit/test_cli.py +121 -0
  25. tests/unit/test_cli_package.py +81 -0
  26. tests/unit/test_compile_graph_map.py +132 -0
  27. tests/unit/test_conditions_routing.py +253 -0
  28. tests/unit/test_config.py +93 -0
  29. tests/unit/test_conversation_memory.py +270 -0
  30. tests/unit/test_database.py +145 -0
  31. tests/unit/test_deprecation.py +104 -0
  32. tests/unit/test_executor.py +60 -0
  33. tests/unit/test_executor_async.py +179 -0
  34. tests/unit/test_export.py +150 -0
  35. tests/unit/test_expressions.py +178 -0
  36. tests/unit/test_format_prompt.py +145 -0
  37. tests/unit/test_generic_report.py +200 -0
  38. tests/unit/test_graph_commands.py +327 -0
  39. tests/unit/test_graph_loader.py +299 -0
  40. tests/unit/test_graph_schema.py +193 -0
  41. tests/unit/test_inline_schema.py +151 -0
  42. tests/unit/test_issues.py +164 -0
  43. tests/unit/test_jinja2_prompts.py +85 -0
  44. tests/unit/test_langsmith.py +319 -0
  45. tests/unit/test_llm_factory.py +109 -0
  46. tests/unit/test_llm_factory_async.py +118 -0
  47. tests/unit/test_loops.py +403 -0
  48. tests/unit/test_map_node.py +144 -0
  49. tests/unit/test_no_backward_compat.py +56 -0
  50. tests/unit/test_node_factory.py +225 -0
  51. tests/unit/test_prompts.py +166 -0
  52. tests/unit/test_python_nodes.py +198 -0
  53. tests/unit/test_reliability.py +298 -0
  54. tests/unit/test_result_export.py +234 -0
  55. tests/unit/test_router.py +296 -0
  56. tests/unit/test_sanitize.py +99 -0
  57. tests/unit/test_schema_loader.py +295 -0
  58. tests/unit/test_shell_tools.py +229 -0
  59. tests/unit/test_state_builder.py +331 -0
  60. tests/unit/test_state_builder_map.py +104 -0
  61. tests/unit/test_state_config.py +197 -0
  62. tests/unit/test_template.py +190 -0
  63. tests/unit/test_tool_nodes.py +129 -0
  64. yamlgraph/__init__.py +35 -0
  65. yamlgraph/builder.py +110 -0
  66. yamlgraph/cli/__init__.py +139 -0
  67. yamlgraph/cli/__main__.py +6 -0
  68. yamlgraph/cli/commands.py +232 -0
  69. yamlgraph/cli/deprecation.py +92 -0
  70. yamlgraph/cli/graph_commands.py +382 -0
  71. yamlgraph/cli/validators.py +37 -0
  72. yamlgraph/config.py +67 -0
  73. yamlgraph/constants.py +66 -0
  74. yamlgraph/error_handlers.py +226 -0
  75. yamlgraph/executor.py +275 -0
  76. yamlgraph/executor_async.py +122 -0
  77. yamlgraph/graph_loader.py +337 -0
  78. yamlgraph/map_compiler.py +138 -0
  79. yamlgraph/models/__init__.py +36 -0
  80. yamlgraph/models/graph_schema.py +141 -0
  81. yamlgraph/models/schemas.py +124 -0
  82. yamlgraph/models/state_builder.py +236 -0
  83. yamlgraph/node_factory.py +240 -0
  84. yamlgraph/routing.py +87 -0
  85. yamlgraph/schema_loader.py +160 -0
  86. yamlgraph/storage/__init__.py +17 -0
  87. yamlgraph/storage/checkpointer.py +72 -0
  88. yamlgraph/storage/database.py +320 -0
  89. yamlgraph/storage/export.py +269 -0
  90. yamlgraph/tools/__init__.py +1 -0
  91. yamlgraph/tools/agent.py +235 -0
  92. yamlgraph/tools/nodes.py +124 -0
  93. yamlgraph/tools/python_tool.py +178 -0
  94. yamlgraph/tools/shell.py +205 -0
  95. yamlgraph/utils/__init__.py +47 -0
  96. yamlgraph/utils/conditions.py +157 -0
  97. yamlgraph/utils/expressions.py +111 -0
  98. yamlgraph/utils/langsmith.py +308 -0
  99. yamlgraph/utils/llm_factory.py +118 -0
  100. yamlgraph/utils/llm_factory_async.py +105 -0
  101. yamlgraph/utils/logging.py +127 -0
  102. yamlgraph/utils/prompts.py +116 -0
  103. yamlgraph/utils/sanitize.py +98 -0
  104. yamlgraph/utils/template.py +102 -0
  105. yamlgraph/utils/validators.py +181 -0
  106. yamlgraph-0.1.1.dist-info/METADATA +854 -0
  107. yamlgraph-0.1.1.dist-info/RECORD +111 -0
  108. yamlgraph-0.1.1.dist-info/WHEEL +5 -0
  109. yamlgraph-0.1.1.dist-info/entry_points.txt +2 -0
  110. yamlgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
  111. yamlgraph-0.1.1.dist-info/top_level.txt +3 -0
@@ -0,0 +1,178 @@
1
+ """Python function loader for type: python nodes.
2
+
3
+ This module enables YAML graphs to call arbitrary Python functions
4
+ by specifying the module path and function name.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import logging
11
+ import os
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class PythonToolConfig:
21
+ """Configuration for a Python tool.
22
+
23
+ Attributes:
24
+ module: Full module path (e.g., "examples.storyboard.nodes.image_node")
25
+ function: Function name within the module
26
+ description: Human-readable description
27
+ """
28
+
29
+ module: str
30
+ function: str
31
+ description: str = ""
32
+
33
+
34
+ def load_python_function(config: PythonToolConfig) -> Callable:
35
+ """Load a Python function from module path.
36
+
37
+ Args:
38
+ config: Python tool configuration
39
+
40
+ Returns:
41
+ The loaded function
42
+
43
+ Raises:
44
+ ImportError: If module cannot be imported
45
+ AttributeError: If function not found in module
46
+ """
47
+ # Ensure current working directory is in path for project imports
48
+ cwd = os.getcwd()
49
+ if cwd not in sys.path:
50
+ sys.path.insert(0, cwd)
51
+
52
+ try:
53
+ module = importlib.import_module(config.module)
54
+ except ImportError as e:
55
+ logger.error(f"Failed to import module: {config.module}")
56
+ raise ImportError(f"Cannot import module '{config.module}': {e}") from e
57
+
58
+ try:
59
+ func = getattr(module, config.function)
60
+ except AttributeError as e:
61
+ logger.error(f"Function not found: {config.function} in {config.module}")
62
+ raise AttributeError(
63
+ f"Function '{config.function}' not found in module '{config.module}'"
64
+ ) from e
65
+
66
+ if not callable(func):
67
+ raise TypeError(f"'{config.function}' in '{config.module}' is not callable")
68
+
69
+ logger.debug(f"Loaded Python function: {config.module}.{config.function}")
70
+ return func
71
+
72
+
73
+ def parse_python_tools(tools_config: dict[str, Any]) -> dict[str, PythonToolConfig]:
74
+ """Parse Python tools from YAML tools section.
75
+
76
+ Only extracts tools with type: python.
77
+
78
+ Args:
79
+ tools_config: Dict from YAML tools: section
80
+
81
+ Returns:
82
+ Registry mapping tool names to PythonToolConfig objects
83
+ """
84
+ registry: dict[str, PythonToolConfig] = {}
85
+
86
+ for name, config in tools_config.items():
87
+ if config.get("type") != "python":
88
+ continue
89
+
90
+ if "module" not in config or "function" not in config:
91
+ logger.warning(
92
+ f"Python tool '{name}' missing 'module' or 'function', skipping"
93
+ )
94
+ continue
95
+
96
+ registry[name] = PythonToolConfig(
97
+ module=config["module"],
98
+ function=config["function"],
99
+ description=config.get("description", ""),
100
+ )
101
+
102
+ return registry
103
+
104
+
105
+ def create_python_node(
106
+ node_name: str,
107
+ node_config: dict[str, Any],
108
+ python_tools: dict[str, PythonToolConfig],
109
+ ) -> Callable[[dict[str, Any]], dict]:
110
+ """Create a node that executes a Python function.
111
+
112
+ The function receives the full state dict and should return
113
+ a partial state update dict.
114
+
115
+ Args:
116
+ node_name: Name of the node in the graph
117
+ node_config: Node configuration from YAML
118
+ python_tools: Registry of available Python tools
119
+
120
+ Returns:
121
+ Node function that executes the Python function
122
+ """
123
+ tool_name = node_config.get("tool") or node_config.get("function")
124
+ if not tool_name:
125
+ raise ValueError(f"Python node '{node_name}' must specify 'tool' or 'function'")
126
+
127
+ if tool_name not in python_tools:
128
+ raise KeyError(f"Python tool '{tool_name}' not found in tools registry")
129
+
130
+ tool_config = python_tools[tool_name]
131
+ state_key = node_config.get("state_key", node_name)
132
+ on_error = node_config.get("on_error", "fail")
133
+
134
+ # Load the function at node creation time
135
+ func = load_python_function(tool_config)
136
+
137
+ def node_fn(state: dict[str, Any]) -> dict:
138
+ """Execute the Python function and return state update."""
139
+ logger.info(f"🐍 Executing Python node: {node_name} -> {tool_name}")
140
+
141
+ try:
142
+ result = func(state)
143
+
144
+ # If function returns a dict, merge with node metadata
145
+ if isinstance(result, dict):
146
+ result["current_step"] = node_name
147
+ return result
148
+ else:
149
+ # Function returned a single value, store in state_key
150
+ return {
151
+ state_key: result,
152
+ "current_step": node_name,
153
+ }
154
+
155
+ except Exception as e:
156
+ logger.error(f"Python node {node_name} failed: {e}")
157
+
158
+ if on_error == "skip":
159
+ from yamlgraph.models import ErrorType, PipelineError
160
+
161
+ errors = list(state.get("errors") or [])
162
+ errors.append(
163
+ PipelineError(
164
+ node=node_name,
165
+ type=ErrorType.UNKNOWN_ERROR,
166
+ message=str(e),
167
+ )
168
+ )
169
+ return {
170
+ state_key: None,
171
+ "current_step": node_name,
172
+ "errors": errors,
173
+ }
174
+ else:
175
+ raise
176
+
177
+ node_fn.__name__ = f"{node_name}_python_node"
178
+ return node_fn
@@ -0,0 +1,205 @@
1
+ """Shell tool executor for running commands with variable substitution.
2
+
3
+ This module provides the core shell tool execution functionality,
4
+ allowing YAML-defined tools to run shell commands with parsed output.
5
+
6
+ Security Model:
7
+ Variables are sanitized with shlex.quote() to prevent shell injection.
8
+ The command template itself is trusted (from YAML config), but all
9
+ user-provided variable values are escaped before substitution.
10
+
11
+ Example:
12
+ command: "git log --author={author}"
13
+ variables: {"author": "$(rm -rf /)"}
14
+ → Executed: git log --author='$(rm -rf /)' (safely quoted)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import shlex
23
+ import subprocess
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class ShellToolConfig:
32
+ """Configuration for a shell tool.
33
+
34
+ Attributes:
35
+ command: Shell command with {variable} placeholders
36
+ description: Human-readable description for LLM tool selection
37
+ parse: Output parsing mode - 'text', 'json', or 'none'
38
+ timeout: Max seconds before command is killed
39
+ working_dir: Directory to run command in
40
+ env: Additional environment variables
41
+ success_codes: Exit codes considered successful
42
+ """
43
+
44
+ command: str
45
+ description: str = ""
46
+ parse: str = "text" # text | json | none
47
+ timeout: int = 30
48
+ working_dir: str = "."
49
+ env: dict[str, str] = field(default_factory=dict)
50
+ success_codes: list[int] = field(default_factory=lambda: [0])
51
+
52
+
53
+ @dataclass
54
+ class ToolResult:
55
+ """Result from executing a shell tool.
56
+
57
+ Attributes:
58
+ success: Whether the command succeeded
59
+ output: Parsed output (str, dict, or None based on parse mode)
60
+ error: Error message if failed
61
+ """
62
+
63
+ success: bool
64
+ output: Any = None
65
+ error: str | None = None
66
+
67
+
68
+ def sanitize_variables(variables: dict[str, Any]) -> dict[str, str]:
69
+ """Sanitize variable values for safe shell substitution.
70
+
71
+ Uses shlex.quote() to escape values, preventing shell injection.
72
+
73
+ Args:
74
+ variables: Raw variable values
75
+
76
+ Returns:
77
+ Sanitized variables safe for shell interpolation
78
+ """
79
+ sanitized = {}
80
+ for key, value in variables.items():
81
+ if value is None:
82
+ sanitized[key] = ""
83
+ elif isinstance(value, (list, dict)):
84
+ # Convert complex types to JSON, then quote
85
+ sanitized[key] = shlex.quote(json.dumps(value))
86
+ else:
87
+ sanitized[key] = shlex.quote(str(value))
88
+ return sanitized
89
+
90
+
91
+ def execute_shell_tool(
92
+ config: ShellToolConfig,
93
+ variables: dict[str, Any],
94
+ sanitize: bool = True,
95
+ ) -> ToolResult:
96
+ """Execute shell command with variable substitution.
97
+
98
+ Args:
99
+ config: Tool configuration with command template
100
+ variables: Values to substitute into command placeholders
101
+ sanitize: Whether to sanitize variables with shlex.quote (default True)
102
+
103
+ Returns:
104
+ ToolResult with success status and parsed output or error
105
+ """
106
+ # Sanitize variables to prevent shell injection
107
+ safe_vars = sanitize_variables(variables) if sanitize else variables
108
+
109
+ # Substitute variables into command
110
+ try:
111
+ command = config.command.format(**safe_vars)
112
+ except KeyError as e:
113
+ return ToolResult(
114
+ success=False,
115
+ error=f"Missing variable: {e}",
116
+ )
117
+
118
+ logger.debug(f"Executing: {command}")
119
+
120
+ # Build environment
121
+ env = os.environ.copy()
122
+ env.update(config.env)
123
+
124
+ try:
125
+ # Security: shell=True is required for command templates with pipes/redirects.
126
+ # All user-provided variables are sanitized via shlex.quote() in sanitize_variables()
127
+ # before substitution, preventing shell injection attacks. The command template
128
+ # itself comes from trusted YAML configuration, not user input.
129
+ result = subprocess.run(
130
+ command,
131
+ shell=True, # nosec B602
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=config.timeout,
135
+ cwd=config.working_dir,
136
+ env=env,
137
+ )
138
+ except subprocess.TimeoutExpired:
139
+ return ToolResult(
140
+ success=False,
141
+ error=f"Command timed out after {config.timeout} seconds",
142
+ )
143
+ except Exception as e:
144
+ return ToolResult(
145
+ success=False,
146
+ error=f"Execution error: {e}",
147
+ )
148
+
149
+ # Check exit code
150
+ if result.returncode not in config.success_codes:
151
+ return ToolResult(
152
+ success=False,
153
+ output=result.stdout,
154
+ error=result.stderr or f"Exit code {result.returncode}",
155
+ )
156
+
157
+ # Parse output
158
+ output = result.stdout
159
+ if config.parse == "json":
160
+ try:
161
+ output = json.loads(output)
162
+ except json.JSONDecodeError as e:
163
+ return ToolResult(
164
+ success=False,
165
+ error=f"JSON parse error: {e}",
166
+ )
167
+ elif config.parse == "none":
168
+ output = None
169
+
170
+ return ToolResult(success=True, output=output)
171
+
172
+
173
+ def parse_tools(tools_config: dict[str, Any]) -> dict[str, ShellToolConfig]:
174
+ """Parse tools: section from YAML into ShellToolConfig registry.
175
+
176
+ Only parses shell tools (type: shell or no type specified with command).
177
+ Skips Python tools (type: python).
178
+
179
+ Args:
180
+ tools_config: Dict from YAML tools: section
181
+
182
+ Returns:
183
+ Registry mapping tool names to ShellToolConfig objects
184
+ """
185
+ registry: dict[str, ShellToolConfig] = {}
186
+
187
+ for name, config in tools_config.items():
188
+ # Skip Python tools
189
+ if config.get("type") == "python":
190
+ continue
191
+ # Skip tools without command (invalid shell tools)
192
+ if "command" not in config:
193
+ continue
194
+
195
+ registry[name] = ShellToolConfig(
196
+ command=config["command"],
197
+ description=config.get("description", ""),
198
+ parse=config.get("parse", "text"),
199
+ timeout=config.get("timeout", 30),
200
+ working_dir=config.get("working_dir", "."),
201
+ env=config.get("env", {}),
202
+ success_codes=config.get("success_codes", [0]),
203
+ )
204
+
205
+ return registry
@@ -0,0 +1,47 @@
1
+ """Utility functions for observability and logging."""
2
+
3
+ from yamlgraph.utils.conditions import evaluate_condition
4
+ from yamlgraph.utils.expressions import (
5
+ resolve_state_expression,
6
+ resolve_state_path,
7
+ resolve_template,
8
+ )
9
+ from yamlgraph.utils.langsmith import (
10
+ get_client,
11
+ get_latest_run_id,
12
+ get_project_name,
13
+ get_run_url,
14
+ is_tracing_enabled,
15
+ log_execution,
16
+ print_run_tree,
17
+ )
18
+ from yamlgraph.utils.logging import get_logger, setup_logging
19
+ from yamlgraph.utils.prompts import load_prompt, load_prompt_path, resolve_prompt_path
20
+ from yamlgraph.utils.template import extract_variables, validate_variables
21
+
22
+ __all__ = [
23
+ # Conditions
24
+ "evaluate_condition",
25
+ # Expression resolution (consolidated)
26
+ "resolve_state_path",
27
+ "resolve_state_expression",
28
+ "resolve_template",
29
+ # LangSmith
30
+ "get_client",
31
+ "get_project_name",
32
+ "is_tracing_enabled",
33
+ "get_latest_run_id",
34
+ "print_run_tree",
35
+ "log_execution",
36
+ "get_run_url",
37
+ # Logging
38
+ "get_logger",
39
+ "setup_logging",
40
+ # Prompts
41
+ "resolve_prompt_path",
42
+ "load_prompt",
43
+ "load_prompt_path",
44
+ # Template utilities
45
+ "extract_variables",
46
+ "validate_variables",
47
+ ]
@@ -0,0 +1,157 @@
1
+ """Condition expression evaluation for graph routing.
2
+
3
+ Provides safe evaluation of condition expressions without using eval().
4
+ Supports comparisons and compound boolean expressions.
5
+
6
+ Examples:
7
+ >>> evaluate_condition("score < 0.8", {"score": 0.5})
8
+ True
9
+ >>> evaluate_condition("a > 1 and b < 2", {"a": 2, "b": 1})
10
+ True
11
+ """
12
+
13
+ import re
14
+ from typing import Any
15
+
16
+ from yamlgraph.utils.expressions import resolve_state_path
17
+
18
+ # Regex patterns for expression parsing
19
+ # Valid operators: <=, >=, ==, !=, <, > (strict matching)
20
+ COMPARISON_PATTERN = re.compile(
21
+ r"^\s*([a-zA-Z_][\w.]*)\s*(<=|>=|==|!=|<(?!<)|>(?!>))\s*(.+?)\s*$"
22
+ )
23
+ COMPOUND_AND_PATTERN = re.compile(r"\s+and\s+", re.IGNORECASE)
24
+ COMPOUND_OR_PATTERN = re.compile(r"\s+or\s+", re.IGNORECASE)
25
+
26
+
27
+ def resolve_value(path: str, state: dict) -> Any:
28
+ """Resolve a dotted path to a value from state.
29
+
30
+ Delegates to consolidated resolve_state_path in expressions module.
31
+
32
+ Args:
33
+ path: Dotted path like "critique.score"
34
+ state: State dictionary
35
+
36
+ Returns:
37
+ Resolved value or None if not found
38
+ """
39
+ return resolve_state_path(path, state)
40
+
41
+
42
+ def parse_literal(value_str: str) -> Any:
43
+ """Parse a literal value from expression.
44
+
45
+ Args:
46
+ value_str: String representation of value
47
+
48
+ Returns:
49
+ Parsed Python value
50
+ """
51
+ value_str = value_str.strip()
52
+
53
+ # Handle quoted strings
54
+ if (value_str.startswith('"') and value_str.endswith('"')) or (
55
+ value_str.startswith("'") and value_str.endswith("'")
56
+ ):
57
+ return value_str[1:-1]
58
+
59
+ # Handle special keywords
60
+ if value_str.lower() == "true":
61
+ return True
62
+ if value_str.lower() == "false":
63
+ return False
64
+ if value_str.lower() == "null" or value_str.lower() == "none":
65
+ return None
66
+
67
+ # Handle numbers
68
+ try:
69
+ if "." in value_str:
70
+ return float(value_str)
71
+ return int(value_str)
72
+ except ValueError:
73
+ # Return as string if not a number
74
+ return value_str
75
+
76
+
77
+ def evaluate_comparison(
78
+ left_path: str, operator: str, right_str: str, state: dict[str, Any]
79
+ ) -> bool:
80
+ """Evaluate a single comparison expression.
81
+
82
+ Args:
83
+ left_path: Dotted path to left value
84
+ operator: Comparison operator
85
+ right_str: String representation of right value
86
+ state: State dictionary
87
+
88
+ Returns:
89
+ Boolean result of comparison
90
+ """
91
+ left_value = resolve_value(left_path, state)
92
+ right_value = parse_literal(right_str)
93
+
94
+ # Handle missing left value
95
+ if left_value is None and operator not in ("==", "!="):
96
+ return False
97
+
98
+ try:
99
+ if operator == "<":
100
+ return left_value < right_value
101
+ elif operator == ">":
102
+ return left_value > right_value
103
+ elif operator == "<=":
104
+ return left_value <= right_value
105
+ elif operator == ">=":
106
+ return left_value >= right_value
107
+ elif operator == "==":
108
+ return left_value == right_value
109
+ elif operator == "!=":
110
+ return left_value != right_value
111
+ else:
112
+ raise ValueError(f"Unknown operator: {operator}")
113
+ except TypeError:
114
+ # Comparison failed (e.g., comparing None with number)
115
+ return False
116
+
117
+
118
+ def evaluate_condition(expr: str, state: dict) -> bool:
119
+ """Safely evaluate a condition expression against state.
120
+
121
+ Uses pattern matching - no eval() for security.
122
+
123
+ Args:
124
+ expr: Condition expression like "score < 0.8" or "a > 1 and b < 2"
125
+ state: State dictionary to evaluate against
126
+
127
+ Returns:
128
+ Boolean result of evaluation
129
+
130
+ Raises:
131
+ ValueError: If expression is malformed
132
+
133
+ Examples:
134
+ >>> evaluate_condition("score < 0.8", {"score": 0.5})
135
+ True
136
+ >>> evaluate_condition("critique.score >= 0.8", {"critique": obj})
137
+ True
138
+ """
139
+ expr = expr.strip()
140
+
141
+ # Handle compound OR (lower precedence)
142
+ if COMPOUND_OR_PATTERN.search(expr):
143
+ parts = COMPOUND_OR_PATTERN.split(expr)
144
+ return any(evaluate_condition(part, state) for part in parts)
145
+
146
+ # Handle compound AND
147
+ if COMPOUND_AND_PATTERN.search(expr):
148
+ parts = COMPOUND_AND_PATTERN.split(expr)
149
+ return all(evaluate_condition(part, state) for part in parts)
150
+
151
+ # Parse single comparison
152
+ match = COMPARISON_PATTERN.match(expr)
153
+ if not match:
154
+ raise ValueError(f"Invalid condition expression: {expr}")
155
+
156
+ left_path, operator, right_str = match.groups()
157
+ return evaluate_comparison(left_path, operator, right_str, state)
@@ -0,0 +1,111 @@
1
+ """Expression resolution utilities for YAML graphs.
2
+
3
+ Consolidated module for all state path/expression resolution.
4
+ Use these functions instead of duplicating resolution logic elsewhere.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+
10
+ def resolve_state_path(path: str, state: dict[str, Any]) -> Any:
11
+ """Resolve a dotted path to a value from state.
12
+
13
+ Core resolution function - handles nested dict access and object attributes.
14
+ This is the single source of truth for path resolution.
15
+
16
+ Args:
17
+ path: Dotted path like "critique.score" or "story.panels"
18
+ state: State dictionary
19
+
20
+ Returns:
21
+ Resolved value or None if not found
22
+ """
23
+ if not path:
24
+ return None
25
+
26
+ parts = path.split(".")
27
+ value = state
28
+
29
+ for part in parts:
30
+ if value is None:
31
+ return None
32
+ if isinstance(value, dict):
33
+ value = value.get(part)
34
+ else:
35
+ # Try attribute access for objects (Pydantic models, etc.)
36
+ value = getattr(value, part, None)
37
+
38
+ return value
39
+
40
+
41
+ def resolve_state_expression(expr: str | Any, state: dict[str, Any]) -> Any:
42
+ """Resolve {state.path.to.value} expressions.
43
+
44
+ Supports expressions like:
45
+ - "{name}" -> state["name"]
46
+ - "{state.story.panels}" -> state["story"]["panels"]
47
+ - "{story.title}" -> state["story"]["title"]
48
+
49
+ Non-expression values (no braces) pass through unchanged.
50
+
51
+ Args:
52
+ expr: Expression string like "{state.story.panels}" or any value
53
+ state: Current graph state dict
54
+
55
+ Returns:
56
+ Resolved value from state, or original value if not an expression
57
+
58
+ Raises:
59
+ KeyError: If path cannot be resolved in state
60
+ """
61
+ if not isinstance(expr, str):
62
+ return expr
63
+
64
+ if not (expr.startswith("{") and expr.endswith("}")):
65
+ return expr
66
+
67
+ path = expr[1:-1] # Remove braces
68
+
69
+ # Handle "state." prefix (optional)
70
+ if path.startswith("state."):
71
+ path = path[6:] # Remove "state."
72
+
73
+ # Navigate nested path
74
+ value = state
75
+ for key in path.split("."):
76
+ if isinstance(value, dict) and key in value:
77
+ value = value[key]
78
+ elif hasattr(value, key):
79
+ # Support object attribute access (Pydantic models, etc.)
80
+ value = getattr(value, key)
81
+ else:
82
+ raise KeyError(f"Cannot resolve '{key}' in path '{expr}'")
83
+
84
+ return value
85
+
86
+
87
+ def resolve_template(template: str | Any, state: dict[str, Any]) -> Any:
88
+ """Resolve a {state.field} template to its value.
89
+
90
+ Unlike resolve_state_expression, returns None instead of raising
91
+ when path not found. Used for optional variable resolution.
92
+
93
+ Args:
94
+ template: Template string like "{state.field}" or "{state.obj.attr}"
95
+ state: Current pipeline state
96
+
97
+ Returns:
98
+ Resolved value or None if not found
99
+ """
100
+ STATE_PREFIX = "{state."
101
+ STATE_SUFFIX = "}"
102
+
103
+ if not isinstance(template, str):
104
+ return template
105
+
106
+ if not (template.startswith(STATE_PREFIX) and template.endswith(STATE_SUFFIX)):
107
+ return template
108
+
109
+ # Extract path: "{state.foo.bar}" -> "foo.bar"
110
+ path = template[len(STATE_PREFIX) : -len(STATE_SUFFIX)]
111
+ return resolve_state_path(path, state)