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,324 @@
1
+ """Tests for yamlgraph.utils.prompts module.
2
+
3
+ TDD: Red phase - write tests before implementation.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+
11
+ class TestResolvePromptPath:
12
+ """Tests for resolve_prompt_path function."""
13
+
14
+ def test_resolve_standard_prompt(self, tmp_path: Path):
15
+ """Should resolve prompt in standard prompts/ directory."""
16
+ from yamlgraph.utils.prompts import resolve_prompt_path
17
+
18
+ # Create temp prompt file
19
+ prompts_dir = tmp_path / "prompts"
20
+ prompts_dir.mkdir()
21
+ prompt_file = prompts_dir / "greet.yaml"
22
+ prompt_file.write_text("system: Hello\nuser: Hi {name}")
23
+
24
+ result = resolve_prompt_path("greet", prompts_dir=prompts_dir)
25
+
26
+ assert result == prompt_file
27
+ assert result.exists()
28
+
29
+ def test_resolve_nested_prompt(self, tmp_path: Path):
30
+ """Should resolve nested prompt like map-demo/generate_ideas."""
31
+ from yamlgraph.utils.prompts import resolve_prompt_path
32
+
33
+ # Create nested prompt structure
34
+ prompts_dir = tmp_path / "prompts"
35
+ nested_dir = prompts_dir / "map-demo"
36
+ nested_dir.mkdir(parents=True)
37
+ prompt_file = nested_dir / "generate_ideas.yaml"
38
+ prompt_file.write_text("system: Generate\nuser: {topic}")
39
+
40
+ result = resolve_prompt_path("map-demo/generate_ideas", prompts_dir=prompts_dir)
41
+
42
+ assert result == prompt_file
43
+
44
+ def test_resolve_external_example_prompt(self, tmp_path: Path, monkeypatch):
45
+ """Should resolve external example like examples/storyboard/expand_story."""
46
+ from yamlgraph.utils.prompts import resolve_prompt_path
47
+
48
+ # Create external example structure: {parent}/prompts/{basename}.yaml
49
+ example_dir = tmp_path / "examples" / "storyboard"
50
+ prompts_subdir = example_dir / "prompts"
51
+ prompts_subdir.mkdir(parents=True)
52
+ prompt_file = prompts_subdir / "expand_story.yaml"
53
+ prompt_file.write_text("system: Expand\nuser: {story}")
54
+
55
+ # Change to tmp_path so relative paths resolve correctly
56
+ monkeypatch.chdir(tmp_path)
57
+
58
+ # Standard prompts dir doesn't have it, should fall back to external
59
+ prompts_dir = tmp_path / "prompts"
60
+ prompts_dir.mkdir(exist_ok=True)
61
+
62
+ result = resolve_prompt_path(
63
+ "examples/storyboard/expand_story",
64
+ prompts_dir=prompts_dir,
65
+ )
66
+
67
+ assert result.resolve() == prompt_file.resolve()
68
+
69
+ def test_resolve_nonexistent_raises(self, tmp_path: Path):
70
+ """Should raise FileNotFoundError for missing prompt."""
71
+ from yamlgraph.utils.prompts import resolve_prompt_path
72
+
73
+ prompts_dir = tmp_path / "prompts"
74
+ prompts_dir.mkdir()
75
+
76
+ with pytest.raises(FileNotFoundError, match="Prompt not found"):
77
+ resolve_prompt_path("nonexistent", prompts_dir=prompts_dir)
78
+
79
+ def test_resolve_uses_default_prompts_dir(self):
80
+ """Should use PROMPTS_DIR from config when not specified."""
81
+ from yamlgraph.utils.prompts import resolve_prompt_path
82
+
83
+ # This should find the real greet.yaml in prompts/
84
+ result = resolve_prompt_path("greet")
85
+
86
+ assert result.exists()
87
+ assert result.name == "greet.yaml"
88
+
89
+
90
+ class TestLoadPrompt:
91
+ """Tests for load_prompt function."""
92
+
93
+ def test_load_existing_prompt(self, tmp_path: Path):
94
+ """Should load and parse YAML prompt file."""
95
+ from yamlgraph.utils.prompts import load_prompt
96
+
97
+ # Create temp prompt file
98
+ prompts_dir = tmp_path / "prompts"
99
+ prompts_dir.mkdir()
100
+ prompt_file = prompts_dir / "test.yaml"
101
+ prompt_file.write_text("system: You are helpful\nuser: Hello {name}")
102
+
103
+ result = load_prompt("test", prompts_dir=prompts_dir)
104
+
105
+ assert result["system"] == "You are helpful"
106
+ assert result["user"] == "Hello {name}"
107
+
108
+ def test_load_prompt_with_schema(self, tmp_path: Path):
109
+ """Should load prompt with inline schema section."""
110
+ from yamlgraph.utils.prompts import load_prompt
111
+
112
+ prompts_dir = tmp_path / "prompts"
113
+ prompts_dir.mkdir()
114
+ prompt_file = prompts_dir / "structured.yaml"
115
+ prompt_file.write_text("""
116
+ system: Analyze content
117
+ user: "{content}"
118
+ schema:
119
+ name: Analysis
120
+ fields:
121
+ summary:
122
+ type: str
123
+ description: Brief summary
124
+ """)
125
+
126
+ result = load_prompt("structured", prompts_dir=prompts_dir)
127
+
128
+ assert "schema" in result
129
+ assert result["schema"]["name"] == "Analysis"
130
+
131
+ def test_load_nonexistent_raises(self, tmp_path: Path):
132
+ """Should raise FileNotFoundError for missing prompt."""
133
+ from yamlgraph.utils.prompts import load_prompt
134
+
135
+ prompts_dir = tmp_path / "prompts"
136
+ prompts_dir.mkdir()
137
+
138
+ with pytest.raises(FileNotFoundError):
139
+ load_prompt("missing", prompts_dir=prompts_dir)
140
+
141
+ def test_load_real_generate_prompt(self):
142
+ """Should load the real generate.yaml from prompts/."""
143
+ from yamlgraph.utils.prompts import load_prompt
144
+
145
+ result = load_prompt("generate")
146
+
147
+ assert "system" in result
148
+ assert "user" in result
149
+
150
+
151
+ class TestGraphRelativePrompts:
152
+ """Tests for graph-relative prompt resolution (FR-A)."""
153
+
154
+ def test_resolve_prompt_relative_to_graph(self, tmp_path: Path):
155
+ """Should resolve prompt relative to graph file location."""
156
+ from yamlgraph.utils.prompts import resolve_prompt_path
157
+
158
+ # Create graph structure with colocated prompts
159
+ # questionnaires/audit/graph.yaml
160
+ # questionnaires/audit/prompts/opening.yaml
161
+ graph_dir = tmp_path / "questionnaires" / "audit"
162
+ prompts_dir = graph_dir / "prompts"
163
+ prompts_dir.mkdir(parents=True)
164
+
165
+ graph_file = graph_dir / "graph.yaml"
166
+ graph_file.write_text("name: audit")
167
+
168
+ prompt_file = prompts_dir / "opening.yaml"
169
+ prompt_file.write_text("system: Welcome\nuser: Start audit")
170
+
171
+ # Resolve relative to graph
172
+ result = resolve_prompt_path(
173
+ "prompts/opening",
174
+ graph_path=graph_file,
175
+ prompts_relative=True,
176
+ )
177
+
178
+ assert result == prompt_file
179
+ assert result.exists()
180
+
181
+ def test_resolve_prompt_explicit_prompts_dir(self, tmp_path: Path):
182
+ """Should use explicit prompts_dir when specified."""
183
+ from yamlgraph.utils.prompts import resolve_prompt_path
184
+
185
+ # Create shared prompts directory
186
+ shared_prompts = tmp_path / "shared" / "prompts"
187
+ shared_prompts.mkdir(parents=True)
188
+
189
+ prompt_file = shared_prompts / "greet.yaml"
190
+ prompt_file.write_text("system: Hello\nuser: Hi")
191
+
192
+ result = resolve_prompt_path(
193
+ "greet",
194
+ prompts_dir=shared_prompts,
195
+ )
196
+
197
+ assert result == prompt_file
198
+
199
+ def test_prompts_dir_overrides_relative(self, tmp_path: Path):
200
+ """Explicit prompts_dir should override prompts_relative."""
201
+ from yamlgraph.utils.prompts import resolve_prompt_path
202
+
203
+ # Create both graph-relative and explicit prompts
204
+ graph_dir = tmp_path / "graphs"
205
+ graph_dir.mkdir()
206
+ graph_file = graph_dir / "test.yaml"
207
+ graph_file.write_text("name: test")
208
+
209
+ # Graph-relative prompt (should NOT be used)
210
+ graph_prompts = graph_dir / "prompts"
211
+ graph_prompts.mkdir()
212
+ (graph_prompts / "greet.yaml").write_text("system: Graph local")
213
+
214
+ # Explicit prompts dir (should be used)
215
+ explicit_dir = tmp_path / "explicit"
216
+ explicit_dir.mkdir()
217
+ explicit_prompt = explicit_dir / "greet.yaml"
218
+ explicit_prompt.write_text("system: Explicit")
219
+
220
+ result = resolve_prompt_path(
221
+ "greet",
222
+ prompts_dir=explicit_dir, # Explicit takes precedence
223
+ graph_path=graph_file,
224
+ prompts_relative=True,
225
+ )
226
+
227
+ assert result == explicit_prompt
228
+
229
+ def test_relative_resolution_without_graph_path_raises(self, tmp_path: Path):
230
+ """Should raise if prompts_relative=True but no graph_path."""
231
+ from yamlgraph.utils.prompts import resolve_prompt_path
232
+
233
+ prompts_dir = tmp_path / "prompts"
234
+ prompts_dir.mkdir()
235
+ (prompts_dir / "greet.yaml").write_text("system: Hi")
236
+
237
+ with pytest.raises(ValueError, match="graph_path required"):
238
+ resolve_prompt_path(
239
+ "greet",
240
+ prompts_relative=True,
241
+ # graph_path not provided
242
+ )
243
+
244
+ def test_relative_resolution_nested_prompt(self, tmp_path: Path):
245
+ """Should resolve nested prompts relative to graph."""
246
+ from yamlgraph.utils.prompts import resolve_prompt_path
247
+
248
+ # questionnaires/phq9/graph.yaml
249
+ # questionnaires/phq9/prompts/extract/fields.yaml
250
+ graph_dir = tmp_path / "questionnaires" / "phq9"
251
+ prompts_dir = graph_dir / "prompts" / "extract"
252
+ prompts_dir.mkdir(parents=True)
253
+
254
+ graph_file = graph_dir / "graph.yaml"
255
+ graph_file.write_text("name: phq9")
256
+
257
+ prompt_file = prompts_dir / "fields.yaml"
258
+ prompt_file.write_text("system: Extract\nuser: {text}")
259
+
260
+ result = resolve_prompt_path(
261
+ "prompts/extract/fields",
262
+ graph_path=graph_file,
263
+ prompts_relative=True,
264
+ )
265
+
266
+ assert result == prompt_file
267
+
268
+ def test_prompts_relative_with_prompts_dir_combines_paths(self, tmp_path: Path):
269
+ """When BOTH prompts_relative and prompts_dir are set, combine them.
270
+
271
+ Bug: prompts_dir should be resolved RELATIVE to graph_path.parent
272
+ when prompts_relative=True, not as an absolute/CWD-relative path.
273
+
274
+ Structure:
275
+ questionnaires/audit/
276
+ graph.yaml (with prompts_relative: true, prompts_dir: prompts)
277
+ prompts/
278
+ opening.yaml
279
+
280
+ Expected: "opening" resolves to questionnaires/audit/prompts/opening.yaml
281
+ """
282
+ from yamlgraph.utils.prompts import resolve_prompt_path
283
+
284
+ # Create the structure
285
+ graph_dir = tmp_path / "questionnaires" / "audit"
286
+ prompts_dir = graph_dir / "prompts"
287
+ prompts_dir.mkdir(parents=True)
288
+
289
+ graph_file = graph_dir / "graph.yaml"
290
+ graph_file.write_text("name: audit")
291
+
292
+ prompt_file = prompts_dir / "opening.yaml"
293
+ prompt_file.write_text("system: Welcome\nuser: Start audit")
294
+
295
+ # This should resolve to questionnaires/audit/prompts/opening.yaml
296
+ # Note: prompts_dir is a RELATIVE path "prompts", not an absolute path
297
+ result = resolve_prompt_path(
298
+ "opening",
299
+ prompts_dir="prompts", # Relative path!
300
+ graph_path=graph_file,
301
+ prompts_relative=True,
302
+ )
303
+
304
+ # Should resolve to the prompt colocated with the graph
305
+ assert result == prompt_file
306
+ assert result.exists()
307
+
308
+
309
+ class TestLoadPromptPath:
310
+ """Tests for load_prompt_path (returns Path + parsed content)."""
311
+
312
+ def test_load_prompt_path_returns_both(self, tmp_path: Path):
313
+ """Should return both path and parsed content."""
314
+ from yamlgraph.utils.prompts import load_prompt_path
315
+
316
+ prompts_dir = tmp_path / "prompts"
317
+ prompts_dir.mkdir()
318
+ prompt_file = prompts_dir / "dual.yaml"
319
+ prompt_file.write_text("system: Test\nuser: Hello")
320
+
321
+ path, content = load_prompt_path("dual", prompts_dir=prompts_dir)
322
+
323
+ assert path == prompt_file
324
+ assert content["system"] == "Test"
@@ -0,0 +1,198 @@
1
+ """Tests for Python tool nodes (type: python)."""
2
+
3
+ import pytest
4
+
5
+ from yamlgraph.tools.python_tool import (
6
+ PythonToolConfig,
7
+ create_python_node,
8
+ load_python_function,
9
+ parse_python_tools,
10
+ )
11
+
12
+
13
+ class TestPythonToolConfig:
14
+ """Tests for PythonToolConfig dataclass."""
15
+
16
+ def test_basic_config(self):
17
+ """Can create config with required fields."""
18
+ config = PythonToolConfig(
19
+ module="os.path",
20
+ function="join",
21
+ )
22
+ assert config.module == "os.path"
23
+ assert config.function == "join"
24
+ assert config.description == ""
25
+
26
+ def test_config_with_description(self):
27
+ """Can create config with description."""
28
+ config = PythonToolConfig(
29
+ module="json",
30
+ function="dumps",
31
+ description="Serialize to JSON",
32
+ )
33
+ assert config.description == "Serialize to JSON"
34
+
35
+
36
+ class TestLoadPythonFunction:
37
+ """Tests for load_python_function."""
38
+
39
+ def test_loads_stdlib_function(self):
40
+ """Can load function from stdlib."""
41
+ config = PythonToolConfig(module="os.path", function="join")
42
+ func = load_python_function(config)
43
+ assert callable(func)
44
+ assert func("a", "b") == "a/b"
45
+
46
+ def test_loads_json_dumps(self):
47
+ """Can load json.dumps."""
48
+ config = PythonToolConfig(module="json", function="dumps")
49
+ func = load_python_function(config)
50
+ assert func({"a": 1}) == '{"a": 1}'
51
+
52
+ def test_raises_on_invalid_module(self):
53
+ """Raises ImportError for non-existent module."""
54
+ config = PythonToolConfig(module="nonexistent.module", function="foo")
55
+ with pytest.raises(ImportError, match="Cannot import module"):
56
+ load_python_function(config)
57
+
58
+ def test_raises_on_invalid_function(self):
59
+ """Raises AttributeError for non-existent function."""
60
+ config = PythonToolConfig(module="os.path", function="nonexistent_func")
61
+ with pytest.raises(AttributeError, match="not found in module"):
62
+ load_python_function(config)
63
+
64
+ def test_raises_on_non_callable(self):
65
+ """Raises TypeError if attribute is not callable."""
66
+ config = PythonToolConfig(module="os", function="name")
67
+ with pytest.raises(TypeError, match="not callable"):
68
+ load_python_function(config)
69
+
70
+
71
+ class TestParsePythonTools:
72
+ """Tests for parse_python_tools."""
73
+
74
+ def test_parses_python_tools(self):
75
+ """Extracts only type: python tools."""
76
+ tools_config = {
77
+ "shell_tool": {"command": "echo hello"},
78
+ "python_tool": {
79
+ "type": "python",
80
+ "module": "json",
81
+ "function": "dumps",
82
+ },
83
+ }
84
+ result = parse_python_tools(tools_config)
85
+
86
+ assert len(result) == 1
87
+ assert "python_tool" in result
88
+ assert result["python_tool"].module == "json"
89
+ assert result["python_tool"].function == "dumps"
90
+
91
+ def test_skips_shell_tools(self):
92
+ """Does not include shell tools."""
93
+ tools_config = {
94
+ "git_log": {
95
+ "type": "shell",
96
+ "command": "git log",
97
+ },
98
+ }
99
+ result = parse_python_tools(tools_config)
100
+ assert len(result) == 0
101
+
102
+ def test_skips_incomplete_python_tools(self):
103
+ """Skips Python tools missing module or function."""
104
+ tools_config = {
105
+ "missing_module": {"type": "python", "function": "foo"},
106
+ "missing_function": {"type": "python", "module": "json"},
107
+ }
108
+ result = parse_python_tools(tools_config)
109
+ assert len(result) == 0
110
+
111
+ def test_includes_description(self):
112
+ """Parses description field."""
113
+ tools_config = {
114
+ "my_tool": {
115
+ "type": "python",
116
+ "module": "json",
117
+ "function": "loads",
118
+ "description": "Parse JSON",
119
+ },
120
+ }
121
+ result = parse_python_tools(tools_config)
122
+ assert result["my_tool"].description == "Parse JSON"
123
+
124
+
125
+ class TestCreatePythonNode:
126
+ """Tests for create_python_node."""
127
+
128
+ def test_creates_node_function(self):
129
+ """Creates callable node function."""
130
+ python_tools = {
131
+ "my_tool": PythonToolConfig(
132
+ module="tests.unit.test_python_nodes",
133
+ function="sample_node_function",
134
+ ),
135
+ }
136
+ node_config = {"tool": "my_tool", "state_key": "result"}
137
+
138
+ node_fn = create_python_node("test_node", node_config, python_tools)
139
+ assert callable(node_fn)
140
+
141
+ def test_raises_on_missing_tool(self):
142
+ """Raises if tool not in registry."""
143
+ python_tools = {}
144
+ node_config = {"tool": "nonexistent"}
145
+
146
+ with pytest.raises(KeyError, match="not found"):
147
+ create_python_node("test_node", node_config, python_tools)
148
+
149
+ def test_raises_on_missing_tool_key(self):
150
+ """Raises if node config missing tool key."""
151
+ python_tools = {}
152
+ node_config = {}
153
+
154
+ with pytest.raises(ValueError, match="must specify"):
155
+ create_python_node("test_node", node_config, python_tools)
156
+
157
+ def test_node_returns_dict_from_function(self):
158
+ """Node returns function's dict result with current_step."""
159
+ python_tools = {
160
+ "dict_tool": PythonToolConfig(
161
+ module="tests.unit.test_python_nodes",
162
+ function="sample_node_function",
163
+ ),
164
+ }
165
+ node_config = {"tool": "dict_tool"}
166
+
167
+ node_fn = create_python_node("test_node", node_config, python_tools)
168
+ result = node_fn({"input": "hello"})
169
+
170
+ assert result["current_step"] == "test_node"
171
+ assert "output" in result
172
+
173
+ def test_node_wraps_non_dict_return(self):
174
+ """Node wraps non-dict return in state_key."""
175
+ python_tools = {
176
+ "scalar_tool": PythonToolConfig(
177
+ module="tests.unit.test_python_nodes",
178
+ function="scalar_return_function",
179
+ ),
180
+ }
181
+ node_config = {"tool": "scalar_tool", "state_key": "my_value"}
182
+
183
+ node_fn = create_python_node("test_node", node_config, python_tools)
184
+ result = node_fn({})
185
+
186
+ assert result["my_value"] == 42
187
+ assert result["current_step"] == "test_node"
188
+
189
+
190
+ # Sample functions for testing
191
+ def sample_node_function(state: dict) -> dict:
192
+ """Sample node function that returns a dict."""
193
+ return {"output": f"processed: {state.get('input', 'none')}"}
194
+
195
+
196
+ def scalar_return_function(state: dict) -> int:
197
+ """Sample function that returns a scalar."""
198
+ return 42