yamlgraph 0.3.9__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.
- examples/__init__.py +1 -0
- examples/codegen/__init__.py +5 -0
- examples/codegen/models/__init__.py +13 -0
- examples/codegen/models/schemas.py +76 -0
- examples/codegen/tests/__init__.py +1 -0
- examples/codegen/tests/test_ai_helpers.py +235 -0
- examples/codegen/tests/test_ast_analysis.py +174 -0
- examples/codegen/tests/test_code_analysis.py +134 -0
- examples/codegen/tests/test_code_context.py +301 -0
- examples/codegen/tests/test_code_nav.py +89 -0
- examples/codegen/tests/test_dependency_tools.py +119 -0
- examples/codegen/tests/test_example_tools.py +185 -0
- examples/codegen/tests/test_git_tools.py +112 -0
- examples/codegen/tests/test_impl_agent_schemas.py +193 -0
- examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
- examples/codegen/tests/test_jedi_analysis.py +226 -0
- examples/codegen/tests/test_meta_tools.py +250 -0
- examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
- examples/codegen/tests/test_syntax_tools.py +85 -0
- examples/codegen/tests/test_synthesize_prompt.py +94 -0
- examples/codegen/tests/test_template_tools.py +244 -0
- examples/codegen/tools/__init__.py +80 -0
- examples/codegen/tools/ai_helpers.py +420 -0
- examples/codegen/tools/ast_analysis.py +92 -0
- examples/codegen/tools/code_context.py +180 -0
- examples/codegen/tools/code_nav.py +52 -0
- examples/codegen/tools/dependency_tools.py +120 -0
- examples/codegen/tools/example_tools.py +188 -0
- examples/codegen/tools/git_tools.py +151 -0
- examples/codegen/tools/impl_executor.py +614 -0
- examples/codegen/tools/jedi_analysis.py +311 -0
- examples/codegen/tools/meta_tools.py +202 -0
- examples/codegen/tools/syntax_tools.py +26 -0
- examples/codegen/tools/template_tools.py +356 -0
- examples/fastapi_interview.py +167 -0
- examples/npc/api/__init__.py +1 -0
- examples/npc/api/app.py +100 -0
- examples/npc/api/routes/__init__.py +5 -0
- examples/npc/api/routes/encounter.py +182 -0
- examples/npc/api/session.py +330 -0
- examples/npc/demo.py +387 -0
- examples/npc/nodes/__init__.py +5 -0
- examples/npc/nodes/image_node.py +92 -0
- examples/npc/run_encounter.py +230 -0
- examples/shared/__init__.py +0 -0
- examples/shared/replicate_tool.py +238 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +12 -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 +49 -0
- examples/storyboard/retry_images.py +118 -0
- scripts/demo_async_executor.py +212 -0
- scripts/demo_interview_e2e.py +200 -0
- scripts/demo_streaming.py +140 -0
- scripts/run_interview_demo.py +94 -0
- scripts/test_interrupt_fix.py +26 -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_colocated_prompts.py +139 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +283 -0
- tests/integration/test_npc_api/__init__.py +1 -0
- tests/integration/test_npc_api/test_routes.py +357 -0
- tests/integration/test_npc_api/test_session.py +216 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/integration/test_subgraph_integration.py +295 -0
- tests/integration/test_subgraph_interrupt.py +106 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +355 -0
- tests/unit/test_async_executor.py +346 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_checkpointer_factory.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 +276 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +172 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +149 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_feature_brainstorm.py +194 -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_linter.py +627 -0
- tests/unit/test_graph_loader.py +357 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_interrupt_node.py +182 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_json_extract.py +134 -0
- tests/unit/test_langsmith.py +600 -0
- tests/unit/test_langsmith_tools.py +204 -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 +348 -0
- tests/unit/test_passthrough_node.py +126 -0
- tests/unit/test_prompts.py +324 -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_streaming.py +307 -0
- tests/unit/test_subgraph.py +596 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_call_integration.py +164 -0
- tests/unit/test_tool_call_node.py +178 -0
- tests/unit/test_tool_nodes.py +129 -0
- tests/unit/test_websearch.py +234 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +159 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +231 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +541 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +70 -0
- yamlgraph/error_handlers.py +227 -0
- yamlgraph/executor.py +290 -0
- yamlgraph/executor_async.py +288 -0
- yamlgraph/graph_loader.py +451 -0
- yamlgraph/map_compiler.py +150 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +181 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +768 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +240 -0
- yamlgraph/storage/__init__.py +20 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/checkpointer_factory.py +123 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +320 -0
- yamlgraph/tools/graph_linter.py +388 -0
- yamlgraph/tools/langsmith_tools.py +125 -0
- yamlgraph/tools/nodes.py +126 -0
- yamlgraph/tools/python_tool.py +179 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/tools/websearch.py +242 -0
- yamlgraph/utils/__init__.py +48 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +245 -0
- yamlgraph/utils/json_extract.py +104 -0
- yamlgraph/utils/langsmith.py +416 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +104 -0
- yamlgraph/utils/prompts.py +171 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.3.9.dist-info/METADATA +1105 -0
- yamlgraph-0.3.9.dist-info/RECORD +185 -0
- yamlgraph-0.3.9.dist-info/WHEEL +5 -0
- yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
- yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
- yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Tests for Section 1: Reliability & Error Handling.
|
|
2
|
+
|
|
3
|
+
TDD tests for on_error behaviors and fallback provider chains.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from yamlgraph.graph_loader import GraphConfig
|
|
11
|
+
from yamlgraph.models import PipelineError
|
|
12
|
+
from yamlgraph.node_factory import create_node_function
|
|
13
|
+
|
|
14
|
+
# =============================================================================
|
|
15
|
+
# Test: on_error Configuration Parsing
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestOnErrorConfigParsing:
|
|
20
|
+
"""Tests for parsing on_error config from YAML."""
|
|
21
|
+
|
|
22
|
+
def test_parses_on_error_from_node_config(self):
|
|
23
|
+
"""Node config includes on_error field."""
|
|
24
|
+
config_dict = {
|
|
25
|
+
"version": "1.0",
|
|
26
|
+
"name": "test",
|
|
27
|
+
"nodes": {
|
|
28
|
+
"generate": {
|
|
29
|
+
"prompt": "generate",
|
|
30
|
+
"on_error": "skip",
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"edges": [
|
|
34
|
+
{"from": "START", "to": "generate"},
|
|
35
|
+
{"from": "generate", "to": "END"},
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
config = GraphConfig(config_dict)
|
|
39
|
+
assert config.nodes["generate"]["on_error"] == "skip"
|
|
40
|
+
|
|
41
|
+
def test_parses_max_retries_from_node_config(self):
|
|
42
|
+
"""Node config includes max_retries field."""
|
|
43
|
+
config_dict = {
|
|
44
|
+
"version": "1.0",
|
|
45
|
+
"name": "test",
|
|
46
|
+
"nodes": {
|
|
47
|
+
"generate": {
|
|
48
|
+
"prompt": "generate",
|
|
49
|
+
"max_retries": 5,
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"edges": [
|
|
53
|
+
{"from": "START", "to": "generate"},
|
|
54
|
+
{"from": "generate", "to": "END"},
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
config = GraphConfig(config_dict)
|
|
58
|
+
assert config.nodes["generate"]["max_retries"] == 5
|
|
59
|
+
|
|
60
|
+
def test_parses_fallback_provider_from_node_config(self):
|
|
61
|
+
"""Node config includes fallback provider."""
|
|
62
|
+
config_dict = {
|
|
63
|
+
"version": "1.0",
|
|
64
|
+
"name": "test",
|
|
65
|
+
"nodes": {
|
|
66
|
+
"generate": {
|
|
67
|
+
"prompt": "generate",
|
|
68
|
+
"on_error": "fallback",
|
|
69
|
+
"fallback": {"provider": "anthropic"},
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"edges": [
|
|
73
|
+
{"from": "START", "to": "generate"},
|
|
74
|
+
{"from": "generate", "to": "END"},
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
config = GraphConfig(config_dict)
|
|
78
|
+
assert config.nodes["generate"]["fallback"]["provider"] == "anthropic"
|
|
79
|
+
|
|
80
|
+
def test_validates_on_error_values(self):
|
|
81
|
+
"""Invalid on_error value raises ValueError."""
|
|
82
|
+
config_dict = {
|
|
83
|
+
"version": "1.0",
|
|
84
|
+
"name": "test",
|
|
85
|
+
"nodes": {
|
|
86
|
+
"generate": {
|
|
87
|
+
"prompt": "generate",
|
|
88
|
+
"on_error": "invalid_value",
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"edges": [
|
|
92
|
+
{"from": "START", "to": "generate"},
|
|
93
|
+
{"from": "generate", "to": "END"},
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
with pytest.raises(ValueError, match="on_error"):
|
|
97
|
+
GraphConfig(config_dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# =============================================================================
|
|
101
|
+
# Test: on_error: skip Behavior
|
|
102
|
+
# =============================================================================
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestOnErrorSkip:
|
|
106
|
+
"""Tests for on_error: skip behavior."""
|
|
107
|
+
|
|
108
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
109
|
+
def test_skip_returns_empty_on_failure(self, mock_execute):
|
|
110
|
+
"""Node with on_error: skip returns empty dict on failure."""
|
|
111
|
+
mock_execute.side_effect = Exception("LLM failed")
|
|
112
|
+
|
|
113
|
+
node_config = {
|
|
114
|
+
"prompt": "generate",
|
|
115
|
+
"on_error": "skip",
|
|
116
|
+
"state_key": "generated",
|
|
117
|
+
}
|
|
118
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
119
|
+
|
|
120
|
+
result = node_fn({"topic": "test"})
|
|
121
|
+
|
|
122
|
+
# Should NOT have error, should continue pipeline
|
|
123
|
+
assert "error" not in result
|
|
124
|
+
assert result.get("current_step") == "generate"
|
|
125
|
+
|
|
126
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
127
|
+
def test_skip_logs_warning(self, mock_execute):
|
|
128
|
+
"""Node with on_error: skip logs a warning."""
|
|
129
|
+
mock_execute.side_effect = Exception("LLM failed")
|
|
130
|
+
|
|
131
|
+
node_config = {
|
|
132
|
+
"prompt": "generate",
|
|
133
|
+
"on_error": "skip",
|
|
134
|
+
"state_key": "generated",
|
|
135
|
+
}
|
|
136
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
137
|
+
|
|
138
|
+
with patch("yamlgraph.error_handlers.logger") as mock_logger:
|
|
139
|
+
node_fn({"topic": "test"})
|
|
140
|
+
mock_logger.warning.assert_called()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# =============================================================================
|
|
144
|
+
# Test: on_error: retry Behavior
|
|
145
|
+
# =============================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestOnErrorRetry:
|
|
149
|
+
"""Tests for on_error: retry behavior."""
|
|
150
|
+
|
|
151
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
152
|
+
def test_retry_uses_node_max_retries(self, mock_execute):
|
|
153
|
+
"""Node uses its own max_retries, not global."""
|
|
154
|
+
# Fail first 2 times, succeed on 3rd
|
|
155
|
+
mock_execute.side_effect = [
|
|
156
|
+
Exception("Retry 1"),
|
|
157
|
+
Exception("Retry 2"),
|
|
158
|
+
MagicMock(content="Success"),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
node_config = {
|
|
162
|
+
"prompt": "generate",
|
|
163
|
+
"on_error": "retry",
|
|
164
|
+
"max_retries": 3,
|
|
165
|
+
"state_key": "generated",
|
|
166
|
+
}
|
|
167
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
168
|
+
|
|
169
|
+
result = node_fn({"topic": "test"})
|
|
170
|
+
|
|
171
|
+
assert mock_execute.call_count == 3
|
|
172
|
+
assert "generated" in result
|
|
173
|
+
|
|
174
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
175
|
+
def test_retry_exhausted_returns_error(self, mock_execute):
|
|
176
|
+
"""After max_retries exhausted, returns error."""
|
|
177
|
+
mock_execute.side_effect = Exception("Always fails")
|
|
178
|
+
|
|
179
|
+
node_config = {
|
|
180
|
+
"prompt": "generate",
|
|
181
|
+
"on_error": "retry",
|
|
182
|
+
"max_retries": 2,
|
|
183
|
+
"state_key": "generated",
|
|
184
|
+
}
|
|
185
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
186
|
+
|
|
187
|
+
result = node_fn({"topic": "test"})
|
|
188
|
+
|
|
189
|
+
# 1 initial attempt + 2 retries = 3 total calls
|
|
190
|
+
assert mock_execute.call_count == 3
|
|
191
|
+
assert "errors" in result
|
|
192
|
+
assert isinstance(result["errors"][0], PipelineError)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# Test: on_error: fail Behavior
|
|
197
|
+
# =============================================================================
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestOnErrorFail:
|
|
201
|
+
"""Tests for on_error: fail behavior."""
|
|
202
|
+
|
|
203
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
204
|
+
def test_fail_raises_exception(self, mock_execute):
|
|
205
|
+
"""Node with on_error: fail raises exception."""
|
|
206
|
+
mock_execute.side_effect = Exception("LLM failed")
|
|
207
|
+
|
|
208
|
+
node_config = {
|
|
209
|
+
"prompt": "generate",
|
|
210
|
+
"on_error": "fail",
|
|
211
|
+
"state_key": "generated",
|
|
212
|
+
}
|
|
213
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
214
|
+
|
|
215
|
+
with pytest.raises(Exception, match="LLM failed"):
|
|
216
|
+
node_fn({"topic": "test"})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# =============================================================================
|
|
220
|
+
# Test: on_error: fallback Behavior
|
|
221
|
+
# =============================================================================
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class TestOnErrorFallback:
|
|
225
|
+
"""Tests for on_error: fallback behavior."""
|
|
226
|
+
|
|
227
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
228
|
+
def test_fallback_tries_alternate_provider(self, mock_execute):
|
|
229
|
+
"""Node tries fallback provider on primary failure."""
|
|
230
|
+
# First call (mistral) fails, second call (anthropic) succeeds
|
|
231
|
+
mock_execute.side_effect = [
|
|
232
|
+
Exception("Mistral failed"),
|
|
233
|
+
MagicMock(content="Anthropic success"),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
node_config = {
|
|
237
|
+
"prompt": "generate",
|
|
238
|
+
"provider": "mistral",
|
|
239
|
+
"on_error": "fallback",
|
|
240
|
+
"fallback": {"provider": "anthropic"},
|
|
241
|
+
"state_key": "generated",
|
|
242
|
+
}
|
|
243
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
244
|
+
|
|
245
|
+
result = node_fn({"topic": "test"})
|
|
246
|
+
|
|
247
|
+
assert mock_execute.call_count == 2
|
|
248
|
+
# Second call should use anthropic
|
|
249
|
+
second_call = mock_execute.call_args_list[1]
|
|
250
|
+
assert second_call.kwargs.get("provider") == "anthropic"
|
|
251
|
+
assert "generated" in result
|
|
252
|
+
|
|
253
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
254
|
+
def test_all_providers_fail_returns_error(self, mock_execute):
|
|
255
|
+
"""When all providers fail, returns error with all attempts."""
|
|
256
|
+
mock_execute.side_effect = Exception("All fail")
|
|
257
|
+
|
|
258
|
+
node_config = {
|
|
259
|
+
"prompt": "generate",
|
|
260
|
+
"provider": "mistral",
|
|
261
|
+
"on_error": "fallback",
|
|
262
|
+
"fallback": {"provider": "anthropic"},
|
|
263
|
+
"state_key": "generated",
|
|
264
|
+
}
|
|
265
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
266
|
+
|
|
267
|
+
result = node_fn({"topic": "test"})
|
|
268
|
+
|
|
269
|
+
assert mock_execute.call_count == 2
|
|
270
|
+
assert "errors" in result
|
|
271
|
+
assert isinstance(result["errors"][0], PipelineError)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# =============================================================================
|
|
275
|
+
# Test: Default on_error Behavior
|
|
276
|
+
# =============================================================================
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class TestDefaultOnError:
|
|
280
|
+
"""Tests for default error behavior (no on_error specified)."""
|
|
281
|
+
|
|
282
|
+
@patch("yamlgraph.node_factory.execute_prompt")
|
|
283
|
+
def test_default_behavior_returns_error(self, mock_execute):
|
|
284
|
+
"""Without on_error config, current behavior returns error in state."""
|
|
285
|
+
mock_execute.side_effect = Exception("LLM failed")
|
|
286
|
+
|
|
287
|
+
node_config = {
|
|
288
|
+
"prompt": "generate",
|
|
289
|
+
"state_key": "generated",
|
|
290
|
+
# No on_error specified
|
|
291
|
+
}
|
|
292
|
+
node_fn = create_node_function("generate", node_config, {})
|
|
293
|
+
|
|
294
|
+
result = node_fn({"topic": "test"})
|
|
295
|
+
|
|
296
|
+
# Current default behavior: return error in state (as list for consistency)
|
|
297
|
+
assert "errors" in result
|
|
298
|
+
assert isinstance(result["errors"][0], PipelineError)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Tests for Phase 6.4: Result Export.
|
|
2
|
+
|
|
3
|
+
Tests field-based result export with multiple formats.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SampleModel(BaseModel):
|
|
13
|
+
"""Sample model for testing."""
|
|
14
|
+
|
|
15
|
+
title: str
|
|
16
|
+
content: str
|
|
17
|
+
tags: list[str] = Field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestExportResult:
|
|
21
|
+
"""Tests for export_result function."""
|
|
22
|
+
|
|
23
|
+
def test_export_json_field(self, tmp_path: Path):
|
|
24
|
+
"""Export field as JSON file."""
|
|
25
|
+
from yamlgraph.storage.export import export_result
|
|
26
|
+
|
|
27
|
+
state = {
|
|
28
|
+
"thread_id": "test-123",
|
|
29
|
+
"generated": SampleModel(title="Test", content="Body", tags=["a", "b"]),
|
|
30
|
+
}
|
|
31
|
+
config = {
|
|
32
|
+
"generated": {"format": "json", "filename": "content.json"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
36
|
+
|
|
37
|
+
assert len(paths) == 1
|
|
38
|
+
assert paths[0].name == "content.json"
|
|
39
|
+
assert paths[0].exists()
|
|
40
|
+
|
|
41
|
+
data = json.loads(paths[0].read_text())
|
|
42
|
+
assert data["title"] == "Test"
|
|
43
|
+
assert data["content"] == "Body"
|
|
44
|
+
assert data["tags"] == ["a", "b"]
|
|
45
|
+
|
|
46
|
+
def test_export_markdown_field(self, tmp_path: Path):
|
|
47
|
+
"""Export field as Markdown file."""
|
|
48
|
+
from yamlgraph.storage.export import export_result
|
|
49
|
+
|
|
50
|
+
state = {
|
|
51
|
+
"thread_id": "test-456",
|
|
52
|
+
"summary": "This is the summary content.",
|
|
53
|
+
}
|
|
54
|
+
config = {
|
|
55
|
+
"summary": {"format": "markdown", "filename": "summary.md"},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
59
|
+
|
|
60
|
+
assert len(paths) == 1
|
|
61
|
+
assert paths[0].name == "summary.md"
|
|
62
|
+
content = paths[0].read_text()
|
|
63
|
+
assert "This is the summary content." in content
|
|
64
|
+
|
|
65
|
+
def test_export_text_field(self, tmp_path: Path):
|
|
66
|
+
"""Export field as plain text."""
|
|
67
|
+
from yamlgraph.storage.export import export_result
|
|
68
|
+
|
|
69
|
+
state = {
|
|
70
|
+
"thread_id": "test-789",
|
|
71
|
+
"output": "Plain text output",
|
|
72
|
+
}
|
|
73
|
+
config = {
|
|
74
|
+
"output": {"format": "text", "filename": "output.txt"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
78
|
+
|
|
79
|
+
assert len(paths) == 1
|
|
80
|
+
content = paths[0].read_text()
|
|
81
|
+
assert content == "Plain text output"
|
|
82
|
+
|
|
83
|
+
def test_export_creates_thread_directory(self, tmp_path: Path):
|
|
84
|
+
"""Files are created in thread-specific subdirectory."""
|
|
85
|
+
from yamlgraph.storage.export import export_result
|
|
86
|
+
|
|
87
|
+
state = {
|
|
88
|
+
"thread_id": "my-thread-id",
|
|
89
|
+
"result": "data",
|
|
90
|
+
}
|
|
91
|
+
config = {"result": {"format": "text", "filename": "result.txt"}}
|
|
92
|
+
|
|
93
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
94
|
+
|
|
95
|
+
# Check directory structure
|
|
96
|
+
assert paths[0].parent.name == "my-thread-id"
|
|
97
|
+
assert paths[0].parent.parent == tmp_path
|
|
98
|
+
|
|
99
|
+
def test_export_skips_none_fields(self, tmp_path: Path):
|
|
100
|
+
"""Fields with None value are skipped."""
|
|
101
|
+
from yamlgraph.storage.export import export_result
|
|
102
|
+
|
|
103
|
+
state = {
|
|
104
|
+
"thread_id": "test",
|
|
105
|
+
"present": "value",
|
|
106
|
+
"missing": None,
|
|
107
|
+
}
|
|
108
|
+
config = {
|
|
109
|
+
"present": {"format": "text", "filename": "present.txt"},
|
|
110
|
+
"missing": {"format": "text", "filename": "missing.txt"},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
114
|
+
|
|
115
|
+
# Only one file created
|
|
116
|
+
assert len(paths) == 1
|
|
117
|
+
assert paths[0].name == "present.txt"
|
|
118
|
+
|
|
119
|
+
def test_export_skips_missing_fields(self, tmp_path: Path):
|
|
120
|
+
"""Fields not in state are skipped."""
|
|
121
|
+
from yamlgraph.storage.export import export_result
|
|
122
|
+
|
|
123
|
+
state = {"thread_id": "test"} # No 'data' field
|
|
124
|
+
config = {"data": {"format": "text", "filename": "data.txt"}}
|
|
125
|
+
|
|
126
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
127
|
+
|
|
128
|
+
assert len(paths) == 0
|
|
129
|
+
|
|
130
|
+
def test_export_multiple_fields(self, tmp_path: Path):
|
|
131
|
+
"""Export multiple fields in one call."""
|
|
132
|
+
from yamlgraph.storage.export import export_result
|
|
133
|
+
|
|
134
|
+
state = {
|
|
135
|
+
"thread_id": "multi",
|
|
136
|
+
"summary": "Summary text",
|
|
137
|
+
"data": {"key": "value"},
|
|
138
|
+
}
|
|
139
|
+
config = {
|
|
140
|
+
"summary": {"format": "markdown", "filename": "summary.md"},
|
|
141
|
+
"data": {"format": "json", "filename": "data.json"},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
145
|
+
|
|
146
|
+
assert len(paths) == 2
|
|
147
|
+
names = {p.name for p in paths}
|
|
148
|
+
assert "summary.md" in names
|
|
149
|
+
assert "data.json" in names
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestSerializeToJson:
|
|
153
|
+
"""Tests for JSON serialization helper."""
|
|
154
|
+
|
|
155
|
+
def test_serializes_pydantic_model(self):
|
|
156
|
+
"""Pydantic models serialize properly."""
|
|
157
|
+
from yamlgraph.storage.export import _serialize_to_json
|
|
158
|
+
|
|
159
|
+
model = SampleModel(title="Test", content="Body")
|
|
160
|
+
result = _serialize_to_json(model)
|
|
161
|
+
data = json.loads(result)
|
|
162
|
+
|
|
163
|
+
assert data["title"] == "Test"
|
|
164
|
+
assert data["content"] == "Body"
|
|
165
|
+
|
|
166
|
+
def test_serializes_dict(self):
|
|
167
|
+
"""Regular dicts serialize properly."""
|
|
168
|
+
from yamlgraph.storage.export import _serialize_to_json
|
|
169
|
+
|
|
170
|
+
result = _serialize_to_json({"a": 1, "b": [1, 2, 3]})
|
|
171
|
+
data = json.loads(result)
|
|
172
|
+
|
|
173
|
+
assert data["a"] == 1
|
|
174
|
+
assert data["b"] == [1, 2, 3]
|
|
175
|
+
|
|
176
|
+
def test_serializes_with_indent(self):
|
|
177
|
+
"""JSON output is indented."""
|
|
178
|
+
from yamlgraph.storage.export import _serialize_to_json
|
|
179
|
+
|
|
180
|
+
result = _serialize_to_json({"key": "value"})
|
|
181
|
+
# Indented JSON has newlines
|
|
182
|
+
assert "\n" in result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestPydanticToMarkdown:
|
|
186
|
+
"""Tests for Pydantic to Markdown conversion."""
|
|
187
|
+
|
|
188
|
+
def test_includes_model_name_as_title(self):
|
|
189
|
+
"""Model name becomes the markdown title."""
|
|
190
|
+
from yamlgraph.storage.export import _pydantic_to_markdown
|
|
191
|
+
|
|
192
|
+
model = SampleModel(title="Test", content="Body")
|
|
193
|
+
result = _pydantic_to_markdown(model)
|
|
194
|
+
|
|
195
|
+
assert result.startswith("# SampleModel")
|
|
196
|
+
|
|
197
|
+
def test_formats_list_fields_as_bullets(self):
|
|
198
|
+
"""List fields become bullet points."""
|
|
199
|
+
from yamlgraph.storage.export import _pydantic_to_markdown
|
|
200
|
+
|
|
201
|
+
model = SampleModel(title="Test", content="Body", tags=["one", "two"])
|
|
202
|
+
result = _pydantic_to_markdown(model)
|
|
203
|
+
|
|
204
|
+
assert "- one" in result
|
|
205
|
+
assert "- two" in result
|
|
206
|
+
|
|
207
|
+
def test_formats_scalar_fields_bold(self):
|
|
208
|
+
"""Scalar fields use bold labels."""
|
|
209
|
+
from yamlgraph.storage.export import _pydantic_to_markdown
|
|
210
|
+
|
|
211
|
+
model = SampleModel(title="Test", content="Body")
|
|
212
|
+
result = _pydantic_to_markdown(model)
|
|
213
|
+
|
|
214
|
+
assert "**Title**: Test" in result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TestSerializeToMarkdown:
|
|
218
|
+
"""Tests for markdown serialization."""
|
|
219
|
+
|
|
220
|
+
def test_pydantic_model_uses_pydantic_to_markdown(self):
|
|
221
|
+
"""Pydantic models use _pydantic_to_markdown."""
|
|
222
|
+
from yamlgraph.storage.export import _serialize_to_markdown
|
|
223
|
+
|
|
224
|
+
model = SampleModel(title="Test", content="Body")
|
|
225
|
+
result = _serialize_to_markdown(model)
|
|
226
|
+
|
|
227
|
+
assert "# SampleModel" in result
|
|
228
|
+
|
|
229
|
+
def test_string_value_returns_as_is(self):
|
|
230
|
+
"""String values return as-is."""
|
|
231
|
+
from yamlgraph.storage.export import _serialize_to_markdown
|
|
232
|
+
|
|
233
|
+
result = _serialize_to_markdown("Just a string")
|
|
234
|
+
assert result == "Just a string"
|