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,197 @@
|
|
|
1
|
+
"""Tests for YAML state configuration parsing.
|
|
2
|
+
|
|
3
|
+
Tests the parse_state_config function and state: section handling
|
|
4
|
+
in build_state_class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from yamlgraph.models.state_builder import (
|
|
10
|
+
TYPE_MAP,
|
|
11
|
+
build_state_class,
|
|
12
|
+
parse_state_config,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestParseStateConfig:
|
|
17
|
+
"""Tests for parse_state_config function."""
|
|
18
|
+
|
|
19
|
+
def test_empty_config(self):
|
|
20
|
+
"""Empty state config returns empty dict."""
|
|
21
|
+
result = parse_state_config({})
|
|
22
|
+
assert result == {}
|
|
23
|
+
|
|
24
|
+
def test_simple_str_type(self):
|
|
25
|
+
"""Parse 'str' type."""
|
|
26
|
+
result = parse_state_config({"concept": "str"})
|
|
27
|
+
assert result == {"concept": str}
|
|
28
|
+
|
|
29
|
+
def test_simple_int_type(self):
|
|
30
|
+
"""Parse 'int' type."""
|
|
31
|
+
result = parse_state_config({"count": "int"})
|
|
32
|
+
assert result == {"count": int}
|
|
33
|
+
|
|
34
|
+
def test_simple_float_type(self):
|
|
35
|
+
"""Parse 'float' type."""
|
|
36
|
+
result = parse_state_config({"score": "float"})
|
|
37
|
+
assert result == {"score": float}
|
|
38
|
+
|
|
39
|
+
def test_simple_bool_type(self):
|
|
40
|
+
"""Parse 'bool' type."""
|
|
41
|
+
result = parse_state_config({"enabled": "bool"})
|
|
42
|
+
assert result == {"enabled": bool}
|
|
43
|
+
|
|
44
|
+
def test_simple_list_type(self):
|
|
45
|
+
"""Parse 'list' type."""
|
|
46
|
+
result = parse_state_config({"items": "list"})
|
|
47
|
+
assert result == {"items": list}
|
|
48
|
+
|
|
49
|
+
def test_simple_dict_type(self):
|
|
50
|
+
"""Parse 'dict' type."""
|
|
51
|
+
result = parse_state_config({"metadata": "dict"})
|
|
52
|
+
assert result == {"metadata": dict}
|
|
53
|
+
|
|
54
|
+
def test_any_type(self):
|
|
55
|
+
"""Parse 'any' type."""
|
|
56
|
+
result = parse_state_config({"data": "any"})
|
|
57
|
+
assert result == {"data": Any}
|
|
58
|
+
|
|
59
|
+
def test_type_aliases(self):
|
|
60
|
+
"""Type aliases like 'string', 'integer', 'boolean' work."""
|
|
61
|
+
result = parse_state_config(
|
|
62
|
+
{
|
|
63
|
+
"name": "string",
|
|
64
|
+
"age": "integer",
|
|
65
|
+
"active": "boolean",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
assert result == {"name": str, "age": int, "active": bool}
|
|
69
|
+
|
|
70
|
+
def test_case_insensitive(self):
|
|
71
|
+
"""Type names are case-insensitive."""
|
|
72
|
+
result = parse_state_config(
|
|
73
|
+
{
|
|
74
|
+
"a": "STR",
|
|
75
|
+
"b": "Int",
|
|
76
|
+
"c": "FLOAT",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
assert result == {"a": str, "b": int, "c": float}
|
|
80
|
+
|
|
81
|
+
def test_multiple_fields(self):
|
|
82
|
+
"""Parse multiple fields."""
|
|
83
|
+
result = parse_state_config(
|
|
84
|
+
{
|
|
85
|
+
"concept": "str",
|
|
86
|
+
"count": "int",
|
|
87
|
+
"score": "float",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
assert result == {"concept": str, "count": int, "score": float}
|
|
91
|
+
|
|
92
|
+
def test_unknown_type_defaults_to_any(self):
|
|
93
|
+
"""Unknown type strings default to Any."""
|
|
94
|
+
result = parse_state_config({"custom": "unknown_type"})
|
|
95
|
+
assert result == {"custom": Any}
|
|
96
|
+
|
|
97
|
+
def test_non_string_value_defaults_to_any(self):
|
|
98
|
+
"""Non-string values default to Any."""
|
|
99
|
+
result = parse_state_config(
|
|
100
|
+
{
|
|
101
|
+
"nested": {"type": "str"}, # Dict value, not string
|
|
102
|
+
"number": 123, # Int value, not string
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
assert result == {"nested": Any, "number": Any}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestTypeMap:
|
|
109
|
+
"""Tests for TYPE_MAP constant."""
|
|
110
|
+
|
|
111
|
+
def test_all_basic_types_present(self):
|
|
112
|
+
"""TYPE_MAP contains all basic Python types."""
|
|
113
|
+
assert "str" in TYPE_MAP
|
|
114
|
+
assert "int" in TYPE_MAP
|
|
115
|
+
assert "float" in TYPE_MAP
|
|
116
|
+
assert "bool" in TYPE_MAP
|
|
117
|
+
assert "list" in TYPE_MAP
|
|
118
|
+
assert "dict" in TYPE_MAP
|
|
119
|
+
assert "any" in TYPE_MAP
|
|
120
|
+
|
|
121
|
+
def test_aliases_present(self):
|
|
122
|
+
"""TYPE_MAP contains common aliases."""
|
|
123
|
+
assert "string" in TYPE_MAP
|
|
124
|
+
assert "integer" in TYPE_MAP
|
|
125
|
+
assert "boolean" in TYPE_MAP
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestBuildStateClassWithStateConfig:
|
|
129
|
+
"""Tests for build_state_class with state: section."""
|
|
130
|
+
|
|
131
|
+
def test_state_section_adds_fields(self):
|
|
132
|
+
"""State section fields are included in generated class."""
|
|
133
|
+
config = {
|
|
134
|
+
"state": {"concept": "str", "count": "int"},
|
|
135
|
+
"nodes": {},
|
|
136
|
+
"edges": [],
|
|
137
|
+
}
|
|
138
|
+
state_class = build_state_class(config)
|
|
139
|
+
annotations = state_class.__annotations__
|
|
140
|
+
|
|
141
|
+
assert "concept" in annotations
|
|
142
|
+
assert "count" in annotations
|
|
143
|
+
|
|
144
|
+
def test_state_section_empty(self):
|
|
145
|
+
"""Empty state section doesn't break build."""
|
|
146
|
+
config = {
|
|
147
|
+
"state": {},
|
|
148
|
+
"nodes": {},
|
|
149
|
+
"edges": [],
|
|
150
|
+
}
|
|
151
|
+
state_class = build_state_class(config)
|
|
152
|
+
# Should still have base fields
|
|
153
|
+
assert "thread_id" in state_class.__annotations__
|
|
154
|
+
|
|
155
|
+
def test_state_section_missing(self):
|
|
156
|
+
"""Missing state section is handled."""
|
|
157
|
+
config = {
|
|
158
|
+
"nodes": {},
|
|
159
|
+
"edges": [],
|
|
160
|
+
}
|
|
161
|
+
state_class = build_state_class(config)
|
|
162
|
+
# Should still have base fields
|
|
163
|
+
assert "thread_id" in state_class.__annotations__
|
|
164
|
+
|
|
165
|
+
def test_custom_field_overrides_common(self):
|
|
166
|
+
"""Custom state field can override common field type."""
|
|
167
|
+
config = {
|
|
168
|
+
"state": {"topic": "int"}, # Override str default
|
|
169
|
+
"nodes": {},
|
|
170
|
+
"edges": [],
|
|
171
|
+
}
|
|
172
|
+
state_class = build_state_class(config)
|
|
173
|
+
# The custom field should be present
|
|
174
|
+
assert "topic" in state_class.__annotations__
|
|
175
|
+
|
|
176
|
+
def test_storyboard_example(self):
|
|
177
|
+
"""Test storyboard-style config with concept field."""
|
|
178
|
+
config = {
|
|
179
|
+
"state": {"concept": "str"},
|
|
180
|
+
"nodes": {
|
|
181
|
+
"expand_story": {
|
|
182
|
+
"type": "llm",
|
|
183
|
+
"state_key": "story",
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
"edges": [],
|
|
187
|
+
}
|
|
188
|
+
state_class = build_state_class(config)
|
|
189
|
+
annotations = state_class.__annotations__
|
|
190
|
+
|
|
191
|
+
# Custom field from state section
|
|
192
|
+
assert "concept" in annotations
|
|
193
|
+
# Output field from node
|
|
194
|
+
assert "story" in annotations
|
|
195
|
+
# Base infrastructure fields
|
|
196
|
+
assert "thread_id" in annotations
|
|
197
|
+
assert "errors" in annotations
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for yamlgraph.utils.template module - Variable extraction and validation."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestExtractVariables:
|
|
7
|
+
"""Tests for extract_variables function."""
|
|
8
|
+
|
|
9
|
+
def test_extract_simple_variables(self):
|
|
10
|
+
"""Should extract {var} placeholders."""
|
|
11
|
+
from yamlgraph.utils.template import extract_variables
|
|
12
|
+
|
|
13
|
+
template = "Hello {name}, your style is {style}."
|
|
14
|
+
variables = extract_variables(template)
|
|
15
|
+
assert variables == {"name", "style"}
|
|
16
|
+
|
|
17
|
+
def test_extract_single_variable(self):
|
|
18
|
+
"""Should extract a single variable."""
|
|
19
|
+
from yamlgraph.utils.template import extract_variables
|
|
20
|
+
|
|
21
|
+
template = "Welcome {user}!"
|
|
22
|
+
variables = extract_variables(template)
|
|
23
|
+
assert variables == {"user"}
|
|
24
|
+
|
|
25
|
+
def test_extract_no_variables(self):
|
|
26
|
+
"""Should return empty set when no variables."""
|
|
27
|
+
from yamlgraph.utils.template import extract_variables
|
|
28
|
+
|
|
29
|
+
template = "No variables here"
|
|
30
|
+
variables = extract_variables(template)
|
|
31
|
+
assert variables == set()
|
|
32
|
+
|
|
33
|
+
def test_extract_duplicate_variables(self):
|
|
34
|
+
"""Should deduplicate variables."""
|
|
35
|
+
from yamlgraph.utils.template import extract_variables
|
|
36
|
+
|
|
37
|
+
template = "{name} and {name} again"
|
|
38
|
+
variables = extract_variables(template)
|
|
39
|
+
assert variables == {"name"}
|
|
40
|
+
|
|
41
|
+
def test_extract_jinja2_variable(self):
|
|
42
|
+
"""Should extract {{ var }} Jinja2 variables."""
|
|
43
|
+
from yamlgraph.utils.template import extract_variables
|
|
44
|
+
|
|
45
|
+
template = "Hello {{ name }}!"
|
|
46
|
+
variables = extract_variables(template)
|
|
47
|
+
assert "name" in variables
|
|
48
|
+
|
|
49
|
+
def test_extract_jinja2_variable_with_field_access(self):
|
|
50
|
+
"""Should extract base variable from {{ var.field }}."""
|
|
51
|
+
from yamlgraph.utils.template import extract_variables
|
|
52
|
+
|
|
53
|
+
template = "User: {{ user.name }}"
|
|
54
|
+
variables = extract_variables(template)
|
|
55
|
+
assert "user" in variables
|
|
56
|
+
|
|
57
|
+
def test_extract_jinja2_loop_variable(self):
|
|
58
|
+
"""Should extract iterable from {% for x in items %}."""
|
|
59
|
+
from yamlgraph.utils.template import extract_variables
|
|
60
|
+
|
|
61
|
+
template = "{% for item in items %}{{ item.name }}{% endfor %}"
|
|
62
|
+
variables = extract_variables(template)
|
|
63
|
+
assert "items" in variables
|
|
64
|
+
# 'item' is a loop variable, not a required input
|
|
65
|
+
assert "item" not in variables
|
|
66
|
+
|
|
67
|
+
def test_extract_jinja2_if_variable(self):
|
|
68
|
+
"""Should extract variable from {% if condition %}."""
|
|
69
|
+
from yamlgraph.utils.template import extract_variables
|
|
70
|
+
|
|
71
|
+
template = "{% if show_details %}Details here{% endif %}"
|
|
72
|
+
variables = extract_variables(template)
|
|
73
|
+
assert "show_details" in variables
|
|
74
|
+
|
|
75
|
+
def test_exclude_state_variable(self):
|
|
76
|
+
"""State is injected by framework, not a required input."""
|
|
77
|
+
from yamlgraph.utils.template import extract_variables
|
|
78
|
+
|
|
79
|
+
template = "{{ state.topic }}"
|
|
80
|
+
variables = extract_variables(template)
|
|
81
|
+
# state is excluded - it's injected by node_factory
|
|
82
|
+
assert "state" not in variables
|
|
83
|
+
|
|
84
|
+
def test_exclude_jinja2_builtins(self):
|
|
85
|
+
"""Should exclude Jinja2 builtins like loop, range."""
|
|
86
|
+
from yamlgraph.utils.template import extract_variables
|
|
87
|
+
|
|
88
|
+
template = "{% for i in range(10) %}{{ loop.index }}{% endfor %}"
|
|
89
|
+
variables = extract_variables(template)
|
|
90
|
+
assert "range" not in variables
|
|
91
|
+
assert "loop" not in variables
|
|
92
|
+
|
|
93
|
+
def test_mixed_simple_and_jinja2(self):
|
|
94
|
+
"""Should handle templates mixing {var} and {{ var }}."""
|
|
95
|
+
from yamlgraph.utils.template import extract_variables
|
|
96
|
+
|
|
97
|
+
template = "Simple {name} and Jinja2 {{ topic }}"
|
|
98
|
+
variables = extract_variables(template)
|
|
99
|
+
assert "name" in variables
|
|
100
|
+
assert "topic" in variables
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestValidateVariables:
|
|
104
|
+
"""Tests for validate_variables function."""
|
|
105
|
+
|
|
106
|
+
def test_validate_all_provided(self):
|
|
107
|
+
"""Should not raise when all variables provided."""
|
|
108
|
+
from yamlgraph.utils.template import validate_variables
|
|
109
|
+
|
|
110
|
+
template = "Hello {name}, style: {style}"
|
|
111
|
+
# Should not raise
|
|
112
|
+
validate_variables(template, {"name": "World", "style": "formal"}, "greet")
|
|
113
|
+
|
|
114
|
+
def test_validate_missing_single_variable(self):
|
|
115
|
+
"""Should raise ValueError for single missing variable."""
|
|
116
|
+
from yamlgraph.utils.template import validate_variables
|
|
117
|
+
|
|
118
|
+
template = "Hello {name}, style: {style}"
|
|
119
|
+
with pytest.raises(ValueError, match="Missing required variable.*name"):
|
|
120
|
+
validate_variables(template, {"style": "formal"}, "greet")
|
|
121
|
+
|
|
122
|
+
def test_validate_missing_multiple_variables(self):
|
|
123
|
+
"""Should list ALL missing variables in error."""
|
|
124
|
+
from yamlgraph.utils.template import validate_variables
|
|
125
|
+
|
|
126
|
+
template = "Hello {name}, style: {style}"
|
|
127
|
+
with pytest.raises(ValueError) as exc_info:
|
|
128
|
+
validate_variables(template, {}, "greet")
|
|
129
|
+
error_msg = str(exc_info.value)
|
|
130
|
+
assert "name" in error_msg
|
|
131
|
+
assert "style" in error_msg
|
|
132
|
+
|
|
133
|
+
def test_validate_extra_variables_ok(self):
|
|
134
|
+
"""Should not raise when extra variables provided."""
|
|
135
|
+
from yamlgraph.utils.template import validate_variables
|
|
136
|
+
|
|
137
|
+
template = "Hello {name}"
|
|
138
|
+
# Should not raise - extra vars are fine
|
|
139
|
+
validate_variables(template, {"name": "World", "extra": "ignored"}, "greet")
|
|
140
|
+
|
|
141
|
+
def test_validate_prompt_name_in_error(self):
|
|
142
|
+
"""Error message should include prompt name."""
|
|
143
|
+
from yamlgraph.utils.template import validate_variables
|
|
144
|
+
|
|
145
|
+
template = "Hello {name}"
|
|
146
|
+
with pytest.raises(ValueError, match="greet"):
|
|
147
|
+
validate_variables(template, {}, "greet")
|
|
148
|
+
|
|
149
|
+
def test_validate_empty_template(self):
|
|
150
|
+
"""Should not raise for template without variables."""
|
|
151
|
+
from yamlgraph.utils.template import validate_variables
|
|
152
|
+
|
|
153
|
+
template = "No variables here"
|
|
154
|
+
# Should not raise
|
|
155
|
+
validate_variables(template, {}, "static")
|
|
156
|
+
|
|
157
|
+
def test_validate_jinja2_template(self):
|
|
158
|
+
"""Should validate Jinja2 templates correctly."""
|
|
159
|
+
from yamlgraph.utils.template import validate_variables
|
|
160
|
+
|
|
161
|
+
template = "{% for item in items %}{{ item }}{% endfor %}"
|
|
162
|
+
with pytest.raises(ValueError, match="items"):
|
|
163
|
+
validate_variables(template, {}, "list_template")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestExecutePromptValidation:
|
|
167
|
+
"""Integration tests for validation in execute_prompt."""
|
|
168
|
+
|
|
169
|
+
def test_execute_prompt_raises_on_missing_variable(self):
|
|
170
|
+
"""Should raise clear error when required variable is missing."""
|
|
171
|
+
from yamlgraph.executor import execute_prompt
|
|
172
|
+
|
|
173
|
+
with pytest.raises(ValueError, match="Missing required variable.*name"):
|
|
174
|
+
execute_prompt(
|
|
175
|
+
prompt_name="greet",
|
|
176
|
+
variables={"style": "formal"}, # Missing 'name'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def test_execute_prompt_lists_all_missing_variables(self):
|
|
180
|
+
"""Error should list ALL missing variables, not just first."""
|
|
181
|
+
from yamlgraph.executor import execute_prompt
|
|
182
|
+
|
|
183
|
+
with pytest.raises(ValueError) as exc_info:
|
|
184
|
+
execute_prompt(
|
|
185
|
+
prompt_name="greet",
|
|
186
|
+
variables={}, # Missing both 'name' and 'style'
|
|
187
|
+
)
|
|
188
|
+
error_msg = str(exc_info.value)
|
|
189
|
+
assert "name" in error_msg
|
|
190
|
+
assert "style" in error_msg
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tests for tool nodes (type: tool)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from yamlgraph.tools.nodes import create_tool_node
|
|
6
|
+
from yamlgraph.tools.shell import ShellToolConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestCreateToolNode:
|
|
10
|
+
"""Tests for create_tool_node function."""
|
|
11
|
+
|
|
12
|
+
def test_executes_named_tool(self):
|
|
13
|
+
"""Node runs correct tool from registry."""
|
|
14
|
+
tools = {
|
|
15
|
+
"echo_tool": ShellToolConfig(command="echo hello"),
|
|
16
|
+
"other_tool": ShellToolConfig(command="echo other"),
|
|
17
|
+
}
|
|
18
|
+
node_config = {"tool": "echo_tool"}
|
|
19
|
+
|
|
20
|
+
node_fn = create_tool_node("test_node", node_config, tools)
|
|
21
|
+
result = node_fn({})
|
|
22
|
+
|
|
23
|
+
assert result["test_node"].strip() == "hello"
|
|
24
|
+
assert result["current_step"] == "test_node"
|
|
25
|
+
|
|
26
|
+
def test_resolves_variables_from_state(self):
|
|
27
|
+
"""State values passed to tool."""
|
|
28
|
+
tools = {
|
|
29
|
+
"greet": ShellToolConfig(command="echo Hello {name}"),
|
|
30
|
+
}
|
|
31
|
+
node_config = {
|
|
32
|
+
"tool": "greet",
|
|
33
|
+
"variables": {"name": "{state.user_name}"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
node_fn = create_tool_node("greet_node", node_config, tools)
|
|
37
|
+
result = node_fn({"user_name": "Alice"})
|
|
38
|
+
|
|
39
|
+
assert "Alice" in result["greet_node"]
|
|
40
|
+
|
|
41
|
+
def test_stores_result_in_state_key(self):
|
|
42
|
+
"""Tool output saved to custom state_key."""
|
|
43
|
+
tools = {
|
|
44
|
+
"data_tool": ShellToolConfig(command="echo data_value"),
|
|
45
|
+
}
|
|
46
|
+
node_config = {
|
|
47
|
+
"tool": "data_tool",
|
|
48
|
+
"state_key": "my_data",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
node_fn = create_tool_node("fetch_node", node_config, tools)
|
|
52
|
+
result = node_fn({})
|
|
53
|
+
|
|
54
|
+
assert "my_data" in result
|
|
55
|
+
assert result["my_data"].strip() == "data_value"
|
|
56
|
+
|
|
57
|
+
def test_on_error_skip(self):
|
|
58
|
+
"""Failed tool skipped when on_error: skip."""
|
|
59
|
+
tools = {
|
|
60
|
+
"fail_tool": ShellToolConfig(command="exit 1"),
|
|
61
|
+
}
|
|
62
|
+
node_config = {
|
|
63
|
+
"tool": "fail_tool",
|
|
64
|
+
"on_error": "skip",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
node_fn = create_tool_node("fail_node", node_config, tools)
|
|
68
|
+
result = node_fn({})
|
|
69
|
+
|
|
70
|
+
# Should not raise, should return with error info
|
|
71
|
+
assert result["current_step"] == "fail_node"
|
|
72
|
+
assert "errors" in result or result.get("fail_node") is None
|
|
73
|
+
|
|
74
|
+
def test_on_error_fail_raises(self):
|
|
75
|
+
"""Failed tool raises when on_error: fail."""
|
|
76
|
+
tools = {
|
|
77
|
+
"fail_tool": ShellToolConfig(command="exit 1"),
|
|
78
|
+
}
|
|
79
|
+
node_config = {
|
|
80
|
+
"tool": "fail_tool",
|
|
81
|
+
"on_error": "fail",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
node_fn = create_tool_node("fail_node", node_config, tools)
|
|
85
|
+
|
|
86
|
+
with pytest.raises(RuntimeError):
|
|
87
|
+
node_fn({})
|
|
88
|
+
|
|
89
|
+
def test_nested_state_variable(self):
|
|
90
|
+
"""Nested state values like {state.location.lat} resolved."""
|
|
91
|
+
tools = {
|
|
92
|
+
"geo": ShellToolConfig(command="echo {lat},{lon}"),
|
|
93
|
+
}
|
|
94
|
+
node_config = {
|
|
95
|
+
"tool": "geo",
|
|
96
|
+
"variables": {
|
|
97
|
+
"lat": "{state.location.lat}",
|
|
98
|
+
"lon": "{state.location.lon}",
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
node_fn = create_tool_node("geo_node", node_config, tools)
|
|
103
|
+
result = node_fn({"location": {"lat": "37.7749", "lon": "-122.4194"}})
|
|
104
|
+
|
|
105
|
+
assert "37.7749" in result["geo_node"]
|
|
106
|
+
assert "-122.4194" in result["geo_node"]
|
|
107
|
+
|
|
108
|
+
def test_missing_tool_raises(self):
|
|
109
|
+
"""Unknown tool name raises error."""
|
|
110
|
+
tools = {}
|
|
111
|
+
node_config = {"tool": "nonexistent"}
|
|
112
|
+
|
|
113
|
+
with pytest.raises(KeyError):
|
|
114
|
+
create_tool_node("bad_node", node_config, tools)
|
|
115
|
+
|
|
116
|
+
def test_json_parse_tool(self):
|
|
117
|
+
"""Tool with parse: json returns dict."""
|
|
118
|
+
tools = {
|
|
119
|
+
"json_tool": ShellToolConfig(
|
|
120
|
+
command="echo '{{\"count\": 42}}'",
|
|
121
|
+
parse="json",
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
node_config = {"tool": "json_tool"}
|
|
125
|
+
|
|
126
|
+
node_fn = create_tool_node("json_node", node_config, tools)
|
|
127
|
+
result = node_fn({})
|
|
128
|
+
|
|
129
|
+
assert result["json_node"] == {"count": 42}
|
yamlgraph/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""YamlGraph - YAML-first LLM pipeline framework.
|
|
2
|
+
|
|
3
|
+
Framework for building LLM pipelines with YAML configuration.
|
|
4
|
+
State is generated dynamically from graph config.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from yamlgraph.builder import build_graph, build_resume_graph, run_pipeline
|
|
8
|
+
from yamlgraph.executor import execute_prompt, get_executor
|
|
9
|
+
from yamlgraph.models import (
|
|
10
|
+
ErrorType,
|
|
11
|
+
GenericReport,
|
|
12
|
+
PipelineError,
|
|
13
|
+
build_state_class,
|
|
14
|
+
create_initial_state,
|
|
15
|
+
)
|
|
16
|
+
from yamlgraph.storage import YamlGraphDB
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
# Builder
|
|
20
|
+
"build_graph",
|
|
21
|
+
"build_resume_graph",
|
|
22
|
+
"run_pipeline",
|
|
23
|
+
# Executor
|
|
24
|
+
"execute_prompt",
|
|
25
|
+
"get_executor",
|
|
26
|
+
# Framework models
|
|
27
|
+
"ErrorType",
|
|
28
|
+
"PipelineError",
|
|
29
|
+
"GenericReport",
|
|
30
|
+
# Dynamic state
|
|
31
|
+
"build_state_class",
|
|
32
|
+
"create_initial_state",
|
|
33
|
+
# Storage
|
|
34
|
+
"YamlGraphDB",
|
|
35
|
+
]
|
yamlgraph/builder.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Graph builders for yamlgraph pipelines.
|
|
2
|
+
|
|
3
|
+
Provides functions to build pipeline graphs from YAML configuration.
|
|
4
|
+
|
|
5
|
+
Pipeline Architecture
|
|
6
|
+
=====================
|
|
7
|
+
|
|
8
|
+
The main pipeline follows this flow:
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
graph LR
|
|
12
|
+
A[generate] -->|content| B{should_continue}
|
|
13
|
+
B -->|continue| C[analyze]
|
|
14
|
+
B -->|end| E[END]
|
|
15
|
+
C -->|analysis| D[summarize]
|
|
16
|
+
D --> E[END]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
State Flow:
|
|
20
|
+
- generate: Creates structured content from topic
|
|
21
|
+
- analyze: Produces analysis from generated content
|
|
22
|
+
- summarize: Combines all outputs into final_summary
|
|
23
|
+
|
|
24
|
+
Graph Definition:
|
|
25
|
+
- Pipelines are defined in graphs/*.yaml
|
|
26
|
+
- Loaded and compiled via graph_loader module
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from langgraph.graph import StateGraph
|
|
33
|
+
|
|
34
|
+
from yamlgraph.config import DEFAULT_GRAPH
|
|
35
|
+
from yamlgraph.graph_loader import load_and_compile
|
|
36
|
+
from yamlgraph.models import create_initial_state
|
|
37
|
+
|
|
38
|
+
# Type alias for dynamic state
|
|
39
|
+
GraphState = dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_graph(
|
|
43
|
+
graph_path: Path | str | None = None,
|
|
44
|
+
checkpointer: Any | None = None,
|
|
45
|
+
) -> StateGraph:
|
|
46
|
+
"""Build a pipeline graph from YAML with optional checkpointer.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
graph_path: Path to YAML graph definition.
|
|
50
|
+
Defaults to graphs/yamlgraph.yaml
|
|
51
|
+
checkpointer: Optional LangGraph checkpointer for state persistence.
|
|
52
|
+
Use get_checkpointer() from storage.checkpointer.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
StateGraph ready for compilation
|
|
56
|
+
"""
|
|
57
|
+
path = Path(graph_path) if graph_path else DEFAULT_GRAPH
|
|
58
|
+
graph = load_and_compile(path)
|
|
59
|
+
|
|
60
|
+
# Checkpointer is applied at compile time
|
|
61
|
+
if checkpointer is not None:
|
|
62
|
+
# Store reference for compile() to use
|
|
63
|
+
graph._checkpointer = checkpointer
|
|
64
|
+
|
|
65
|
+
return graph
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_resume_graph() -> StateGraph:
|
|
69
|
+
"""Build a graph for resuming an interrupted pipeline.
|
|
70
|
+
|
|
71
|
+
This is an alias for build_graph(). Resume works automatically
|
|
72
|
+
because nodes skip execution if their output already exists in state
|
|
73
|
+
(skip_if_exists behavior).
|
|
74
|
+
|
|
75
|
+
To resume:
|
|
76
|
+
1. Load saved state from database
|
|
77
|
+
2. Invoke graph with that state
|
|
78
|
+
3. Nodes with existing outputs are skipped
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
StateGraph for resume (same as main pipeline)
|
|
82
|
+
"""
|
|
83
|
+
return build_graph()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_pipeline(
|
|
87
|
+
topic: str,
|
|
88
|
+
style: str = "informative",
|
|
89
|
+
word_count: int = 300,
|
|
90
|
+
graph_path: Path | str | None = None,
|
|
91
|
+
) -> GraphState:
|
|
92
|
+
"""Run the complete pipeline with given inputs.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
topic: Topic to generate content about
|
|
96
|
+
style: Writing style
|
|
97
|
+
word_count: Target word count
|
|
98
|
+
graph_path: Optional path to graph YAML
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Final state with all outputs
|
|
102
|
+
"""
|
|
103
|
+
graph = build_graph(graph_path).compile()
|
|
104
|
+
initial_state = create_initial_state(
|
|
105
|
+
topic=topic,
|
|
106
|
+
style=style,
|
|
107
|
+
word_count=word_count,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return graph.invoke(initial_state)
|