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.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. 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"