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.
- examples/__init__.py +1 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +10 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +238 -0
- examples/storyboard/retry_images.py +118 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +281 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +200 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +270 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +60 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +150 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_loader.py +299 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_langsmith.py +319 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +225 -0
- tests/unit/test_prompts.py +166 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_nodes.py +129 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +139 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +232 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +382 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +66 -0
- yamlgraph/error_handlers.py +226 -0
- yamlgraph/executor.py +275 -0
- yamlgraph/executor_async.py +122 -0
- yamlgraph/graph_loader.py +337 -0
- yamlgraph/map_compiler.py +138 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +141 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +240 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +160 -0
- yamlgraph/storage/__init__.py +17 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +235 -0
- yamlgraph/tools/nodes.py +124 -0
- yamlgraph/tools/python_tool.py +178 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/utils/__init__.py +47 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +111 -0
- yamlgraph/utils/langsmith.py +308 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +127 -0
- yamlgraph/utils/prompts.py +116 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.1.1.dist-info/METADATA +854 -0
- yamlgraph-0.1.1.dist-info/RECORD +111 -0
- yamlgraph-0.1.1.dist-info/WHEEL +5 -0
- yamlgraph-0.1.1.dist-info/entry_points.txt +2 -0
- yamlgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|
yamlgraph/tools/shell.py
ADDED
|
@@ -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)
|