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,179 @@
1
+ """Unit tests for async executor module."""
2
+
3
+ import asyncio
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from yamlgraph.executor_async import execute_prompt_async, execute_prompts_concurrent
9
+ from yamlgraph.utils.llm_factory_async import shutdown_executor
10
+
11
+
12
+ class TestExecutePromptAsync:
13
+ """Tests for execute_prompt_async function."""
14
+
15
+ def teardown_method(self):
16
+ """Clean up executor after each test."""
17
+ shutdown_executor()
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_executes_prompt(self):
21
+ """Should execute a prompt and return result."""
22
+ with patch("yamlgraph.executor_async.invoke_async") as mock_invoke:
23
+ mock_invoke.return_value = "Hello, World!"
24
+
25
+ result = await execute_prompt_async(
26
+ "greet",
27
+ variables={"name": "World", "style": "friendly"},
28
+ )
29
+
30
+ assert result == "Hello, World!"
31
+ mock_invoke.assert_called_once()
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_passes_output_model(self):
35
+ """Should pass output model to invoke_async."""
36
+ from pydantic import BaseModel
37
+
38
+ class TestModel(BaseModel):
39
+ greeting: str
40
+
41
+ with patch("yamlgraph.executor_async.invoke_async") as mock_invoke:
42
+ mock_invoke.return_value = TestModel(greeting="Hi")
43
+
44
+ result = await execute_prompt_async(
45
+ "greet",
46
+ variables={"name": "Test", "style": "casual"},
47
+ output_model=TestModel,
48
+ )
49
+
50
+ assert isinstance(result, TestModel)
51
+ # Check output_model was passed (positional arg)
52
+ call_args = mock_invoke.call_args
53
+ assert call_args[0][2] is TestModel # 3rd positional arg
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_validates_variables(self):
57
+ """Should raise error for missing variables."""
58
+ with pytest.raises(ValueError, match="Missing required variable"):
59
+ await execute_prompt_async("greet", variables={})
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_uses_provider_from_yaml(self):
63
+ """Should extract provider from YAML metadata."""
64
+ with (
65
+ patch("yamlgraph.executor_async.load_prompt") as mock_load,
66
+ patch("yamlgraph.executor_async.invoke_async") as mock_invoke,
67
+ patch("yamlgraph.executor_async.create_llm") as mock_create_llm,
68
+ ):
69
+ mock_load.return_value = {
70
+ "system": "You are helpful.",
71
+ "user": "Hello {name}",
72
+ "provider": "mistral",
73
+ }
74
+ mock_invoke.return_value = "Response"
75
+ mock_create_llm.return_value = MagicMock()
76
+
77
+ await execute_prompt_async("test", variables={"name": "User"})
78
+
79
+ mock_create_llm.assert_called_once()
80
+ call_kwargs = mock_create_llm.call_args[1]
81
+ assert call_kwargs["provider"] == "mistral"
82
+
83
+
84
+ class TestExecutePromptsConcurrent:
85
+ """Tests for execute_prompts_concurrent function."""
86
+
87
+ def teardown_method(self):
88
+ """Clean up executor after each test."""
89
+ shutdown_executor()
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_executes_multiple_prompts(self):
93
+ """Should execute multiple prompts concurrently."""
94
+ with patch(
95
+ "yamlgraph.executor_async.execute_prompt_async", new_callable=AsyncMock
96
+ ) as mock_execute:
97
+ mock_execute.side_effect = ["Result 1", "Result 2", "Result 3"]
98
+
99
+ results = await execute_prompts_concurrent(
100
+ [
101
+ {"prompt_name": "greet", "variables": {"name": "A", "style": "x"}},
102
+ {"prompt_name": "greet", "variables": {"name": "B", "style": "y"}},
103
+ {"prompt_name": "greet", "variables": {"name": "C", "style": "z"}},
104
+ ]
105
+ )
106
+
107
+ assert len(results) == 3
108
+ assert results == ["Result 1", "Result 2", "Result 3"]
109
+ assert mock_execute.call_count == 3
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_preserves_order(self):
113
+ """Should return results in same order as input."""
114
+ with patch(
115
+ "yamlgraph.executor_async.execute_prompt_async", new_callable=AsyncMock
116
+ ) as mock_execute:
117
+ # Simulate varying response times
118
+ async def delayed_response(prompt_name, **kwargs):
119
+ name = kwargs.get("variables", {}).get("name", "")
120
+ if name == "slow":
121
+ await asyncio.sleep(0.01)
122
+ return f"Response for {name}"
123
+
124
+ mock_execute.side_effect = delayed_response
125
+
126
+ results = await execute_prompts_concurrent(
127
+ [
128
+ {
129
+ "prompt_name": "greet",
130
+ "variables": {"name": "slow", "style": "a"},
131
+ },
132
+ {
133
+ "prompt_name": "greet",
134
+ "variables": {"name": "fast", "style": "b"},
135
+ },
136
+ ]
137
+ )
138
+
139
+ assert results[0] == "Response for slow"
140
+ assert results[1] == "Response for fast"
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_empty_list(self):
144
+ """Should handle empty prompt list."""
145
+ results = await execute_prompts_concurrent([])
146
+ assert results == []
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_passes_all_options(self):
150
+ """Should pass all options to execute_prompt_async."""
151
+ from pydantic import BaseModel
152
+
153
+ class TestModel(BaseModel):
154
+ value: str
155
+
156
+ with patch(
157
+ "yamlgraph.executor_async.execute_prompt_async", new_callable=AsyncMock
158
+ ) as mock_execute:
159
+ mock_execute.return_value = TestModel(value="test")
160
+
161
+ await execute_prompts_concurrent(
162
+ [
163
+ {
164
+ "prompt_name": "test",
165
+ "variables": {"x": "y"},
166
+ "output_model": TestModel,
167
+ "temperature": 0.5,
168
+ "provider": "openai",
169
+ }
170
+ ]
171
+ )
172
+
173
+ mock_execute.assert_called_once_with(
174
+ prompt_name="test",
175
+ variables={"x": "y"},
176
+ output_model=TestModel,
177
+ temperature=0.5,
178
+ provider="openai",
179
+ )
@@ -0,0 +1,149 @@
1
+ """Tests for yamlgraph.storage.export module."""
2
+
3
+ import json
4
+
5
+ from tests.conftest import FixtureGeneratedContent
6
+ from yamlgraph.storage.export import _serialize_state, export_state
7
+
8
+
9
+ class TestExportState:
10
+ """Tests for export_state function."""
11
+
12
+ def test_export_creates_file(self, temp_output_dir, sample_state):
13
+ """Export should create a JSON file."""
14
+ filepath = export_state(sample_state, output_dir=temp_output_dir)
15
+ assert filepath.exists()
16
+ assert filepath.suffix == ".json"
17
+
18
+ def test_export_file_contains_valid_json(self, temp_output_dir, sample_state):
19
+ """Exported file should contain valid JSON."""
20
+ filepath = export_state(sample_state, output_dir=temp_output_dir)
21
+ with open(filepath) as f:
22
+ data = json.load(f)
23
+ assert "topic" in data
24
+ assert "thread_id" in data
25
+
26
+ def test_export_filename_format(self, temp_output_dir, sample_state):
27
+ """Filename should include prefix and thread_id."""
28
+ filepath = export_state(
29
+ sample_state,
30
+ output_dir=temp_output_dir,
31
+ prefix="test_export",
32
+ )
33
+ assert "test_export" in filepath.name
34
+ assert sample_state["thread_id"] in filepath.name
35
+
36
+ def test_export_creates_output_dir(self, tmp_path, sample_state):
37
+ """Export should create output directory if it doesn't exist."""
38
+ new_dir = tmp_path / "new_outputs"
39
+ filepath = export_state(sample_state, output_dir=new_dir)
40
+ assert new_dir.exists()
41
+ assert filepath.exists()
42
+
43
+
44
+ class TestSerializeState:
45
+ """Tests for _serialize_state function."""
46
+
47
+ def test_serialize_simple_state(self, empty_state):
48
+ """Simple state should serialize unchanged."""
49
+ result = _serialize_state(empty_state)
50
+ assert result["topic"] == empty_state["topic"]
51
+ assert result["style"] == empty_state["style"]
52
+
53
+ def test_serialize_pydantic_models(self):
54
+ """Pydantic models should be converted to dicts."""
55
+ content = FixtureGeneratedContent(
56
+ title="Test",
57
+ content="Content",
58
+ word_count=1,
59
+ tags=["tag"],
60
+ )
61
+ state = {"generated": content}
62
+ result = _serialize_state(state)
63
+ assert isinstance(result["generated"], dict)
64
+ assert result["generated"]["title"] == "Test"
65
+
66
+ def test_serialize_preserves_none(self, empty_state):
67
+ """None values should be preserved."""
68
+ # Add a None field to test serialization
69
+ empty_state["generated"] = None
70
+ result = _serialize_state(empty_state)
71
+ assert result["generated"] is None
72
+ assert result["error"] is None
73
+
74
+
75
+ class TestExportSummaryGeneric:
76
+ """Tests for generic export_summary behavior."""
77
+
78
+ def test_export_summary_with_any_pydantic_model(self):
79
+ """export_summary should work with any Pydantic model, not just demo-specific ones."""
80
+ from pydantic import BaseModel
81
+
82
+ from yamlgraph.storage.export import export_summary
83
+
84
+ class CustomModel(BaseModel):
85
+ name: str
86
+ value: int
87
+
88
+ state = {
89
+ "thread_id": "test-123",
90
+ "topic": "custom topic",
91
+ "custom_field": CustomModel(name="test", value=42),
92
+ }
93
+
94
+ summary = export_summary(state)
95
+
96
+ # Should include core fields
97
+ assert summary["thread_id"] == "test-123"
98
+ assert summary["topic"] == "custom topic"
99
+
100
+ def test_export_summary_extracts_scalar_fields(self):
101
+ """export_summary should extract key scalar fields from any model."""
102
+ from pydantic import BaseModel
103
+
104
+ from yamlgraph.storage.export import export_summary
105
+
106
+ class ReportContent(BaseModel):
107
+ headline: str
108
+ body: str
109
+ author: str
110
+
111
+ state = {
112
+ "thread_id": "report-1",
113
+ "topic": "report topic",
114
+ "report": ReportContent(
115
+ headline="Breaking News",
116
+ body="Content here...",
117
+ author="Alice",
118
+ ),
119
+ }
120
+
121
+ summary = export_summary(state)
122
+ # Should extract and include scalar fields
123
+ assert "report" in summary or any(k.startswith("report") for k in summary)
124
+
125
+ def test_export_summary_no_demo_model_dependencies(self):
126
+ """export_summary should not import demo-specific model types."""
127
+ import ast
128
+ import inspect
129
+
130
+ from yamlgraph.storage import export
131
+
132
+ source = inspect.getsource(export)
133
+ tree = ast.parse(source)
134
+
135
+ demo_models = {
136
+ "GeneratedContent",
137
+ "Analysis",
138
+ "ToneClassification",
139
+ "DraftContent",
140
+ "Critique",
141
+ "SearchResults",
142
+ "FinalReport",
143
+ }
144
+
145
+ for node in ast.walk(tree):
146
+ if isinstance(node, ast.ImportFrom) and node.module and "schemas" in node.module:
147
+ imported_names = {alias.name for alias in node.names}
148
+ overlap = imported_names & demo_models
149
+ assert not overlap, f"export.py imports demo models: {overlap}"
@@ -0,0 +1,178 @@
1
+ """Tests for state expression resolution."""
2
+
3
+ import pytest
4
+ from pydantic import BaseModel
5
+
6
+ from yamlgraph.utils.expressions import (
7
+ resolve_state_expression,
8
+ resolve_state_path,
9
+ resolve_template,
10
+ )
11
+
12
+
13
+ class TestResolveStateExpression:
14
+ """Tests for resolve_state_expression function."""
15
+
16
+ def test_simple_key(self):
17
+ """Resolve simple state key."""
18
+ state = {"name": "test"}
19
+ result = resolve_state_expression("{name}", state)
20
+ assert result == "test"
21
+
22
+ def test_nested_path(self):
23
+ """Resolve nested path like {state.story.panels}."""
24
+ state = {"story": {"panels": ["a", "b", "c"]}}
25
+ result = resolve_state_expression("{state.story.panels}", state)
26
+ assert result == ["a", "b", "c"]
27
+
28
+ def test_state_prefix_stripped(self):
29
+ """The 'state.' prefix is optional and stripped."""
30
+ state = {"story": {"title": "My Story"}}
31
+ # With prefix
32
+ assert resolve_state_expression("{state.story.title}", state) == "My Story"
33
+ # Without prefix
34
+ assert resolve_state_expression("{story.title}", state) == "My Story"
35
+
36
+ def test_literal_passthrough(self):
37
+ """Non-expression strings pass through unchanged."""
38
+ result = resolve_state_expression("literal string", {})
39
+ assert result == "literal string"
40
+
41
+ def test_non_string_passthrough(self):
42
+ """Non-string values pass through unchanged."""
43
+ result = resolve_state_expression(42, {})
44
+ assert result == 42
45
+
46
+ def test_missing_key_raises(self):
47
+ """Missing key raises KeyError."""
48
+ state = {"foo": "bar"}
49
+ with pytest.raises(KeyError):
50
+ resolve_state_expression("{missing}", state)
51
+
52
+ def test_missing_nested_key_raises(self):
53
+ """Missing nested key raises KeyError."""
54
+ state = {"story": {"title": "test"}}
55
+ with pytest.raises(KeyError):
56
+ resolve_state_expression("{story.panels}", state)
57
+
58
+ def test_deeply_nested_path(self):
59
+ """Resolve deeply nested paths."""
60
+ state = {"a": {"b": {"c": {"d": "deep"}}}}
61
+ result = resolve_state_expression("{a.b.c.d}", state)
62
+ assert result == "deep"
63
+
64
+ def test_list_result(self):
65
+ """Can resolve to list values."""
66
+ state = {"items": [1, 2, 3]}
67
+ result = resolve_state_expression("{items}", state)
68
+ assert result == [1, 2, 3]
69
+
70
+ def test_dict_result(self):
71
+ """Can resolve to dict values."""
72
+ state = {"config": {"key": "value"}}
73
+ result = resolve_state_expression("{config}", state)
74
+ assert result == {"key": "value"}
75
+
76
+ def test_object_attribute_access(self):
77
+ """Can resolve object attributes (Pydantic models)."""
78
+
79
+ class MockModel:
80
+ def __init__(self):
81
+ self.title = "Test Title"
82
+ self.panels = ["panel 1", "panel 2"]
83
+
84
+ state = {"story": MockModel()}
85
+ result = resolve_state_expression("{state.story.panels}", state)
86
+ assert result == ["panel 1", "panel 2"]
87
+
88
+ def test_mixed_dict_and_object_access(self):
89
+ """Can resolve mixed dict and object paths."""
90
+
91
+ class Inner:
92
+ def __init__(self):
93
+ self.value = "found"
94
+
95
+ state = {"outer": {"middle": Inner()}}
96
+ result = resolve_state_expression("{outer.middle.value}", state)
97
+ assert result == "found"
98
+
99
+
100
+ class TestResolveStatePath:
101
+ """Tests for resolve_state_path - the core resolution function."""
102
+
103
+ def test_simple_key(self):
104
+ """Should resolve simple key."""
105
+ state = {"score": 0.8}
106
+ assert resolve_state_path("score", state) == 0.8
107
+
108
+ def test_nested_dict_path(self):
109
+ """Should resolve nested dict path."""
110
+ state = {"critique": {"score": 0.9}}
111
+ assert resolve_state_path("critique.score", state) == 0.9
112
+
113
+ def test_deeply_nested(self):
114
+ """Should resolve deeply nested path."""
115
+ state = {"a": {"b": {"c": {"d": 42}}}}
116
+ assert resolve_state_path("a.b.c.d", state) == 42
117
+
118
+ def test_missing_key_returns_none(self):
119
+ """Should return None for missing key."""
120
+ state = {"a": 1}
121
+ assert resolve_state_path("b", state) is None
122
+
123
+ def test_missing_nested_returns_none(self):
124
+ """Should return None for missing nested path."""
125
+ state = {"a": {"b": 1}}
126
+ assert resolve_state_path("a.c", state) is None
127
+
128
+ def test_pydantic_model_attribute(self):
129
+ """Should resolve Pydantic model attribute."""
130
+
131
+ class Critique(BaseModel):
132
+ score: float
133
+ feedback: str
134
+
135
+ state = {"critique": Critique(score=0.75, feedback="Good")}
136
+ assert resolve_state_path("critique.score", state) == 0.75
137
+ assert resolve_state_path("critique.feedback", state) == "Good"
138
+
139
+ def test_empty_path_returns_none(self):
140
+ """Should return None for empty path."""
141
+ assert resolve_state_path("", {"a": 1}) is None
142
+
143
+
144
+ class TestResolveTemplate:
145
+ """Tests for resolve_template - optional resolution returning None."""
146
+
147
+ def test_state_template(self):
148
+ """Should resolve {state.field} template."""
149
+ state = {"topic": "AI"}
150
+ assert resolve_template("{state.topic}", state) == "AI"
151
+
152
+ def test_nested_template(self):
153
+ """Should resolve nested path template."""
154
+ state = {"config": {"max_tokens": 100}}
155
+ assert resolve_template("{state.config.max_tokens}", state) == 100
156
+
157
+ def test_missing_returns_none(self):
158
+ """Should return None for missing path."""
159
+ state = {"a": 1}
160
+ assert resolve_template("{state.missing}", state) is None
161
+
162
+ def test_non_string_passthrough(self):
163
+ """Should pass through non-string values."""
164
+ assert resolve_template(123, {}) == 123
165
+
166
+ def test_non_state_template_passthrough(self):
167
+ """Should pass through non-state templates."""
168
+ assert resolve_template("{other.field}", {}) == "{other.field}"
169
+ assert resolve_template("plain text", {}) == "plain text"
170
+
171
+ def test_pydantic_model(self):
172
+ """Should resolve Pydantic model attribute."""
173
+
174
+ class Draft(BaseModel):
175
+ text: str
176
+
177
+ state = {"draft": Draft(text="Content")}
178
+ assert resolve_template("{state.draft.text}", state) == "Content"