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,541 @@
1
+ """Graph commands for universal graph runner.
2
+
3
+ Implements:
4
+ - graph run <path> --var key=value
5
+ - graph list
6
+ - graph info <path>
7
+ - graph lint <path>
8
+ """
9
+
10
+ import sys
11
+ from argparse import Namespace
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from yamlgraph.config import WORKING_DIR
17
+ from yamlgraph.tools.graph_linter import lint_graph
18
+
19
+
20
+ def parse_vars(var_list: list[str] | None) -> dict[str, str]:
21
+ """Parse --var key=value arguments into a dict.
22
+
23
+ Args:
24
+ var_list: List of "key=value" strings
25
+
26
+ Returns:
27
+ Dict mapping keys to values
28
+
29
+ Raises:
30
+ ValueError: If a var doesn't contain '='
31
+ """
32
+ if not var_list:
33
+ return {}
34
+
35
+ result = {}
36
+ for item in var_list:
37
+ if "=" not in item:
38
+ raise ValueError(f"Invalid var format: '{item}' (expected key=value)")
39
+ key, value = item.split("=", 1)
40
+ result[key] = value
41
+
42
+ return result
43
+
44
+
45
+ def _display_result(result: dict, truncate: bool = True) -> None:
46
+ """Display result summary to console.
47
+
48
+ Args:
49
+ result: Graph execution result dict
50
+ truncate: Whether to truncate long values (default: True)
51
+ """
52
+ print("=" * 60)
53
+ print("RESULT")
54
+ print("=" * 60)
55
+
56
+ skip_keys = {"messages", "errors", "_loop_counts"}
57
+ for key, value in result.items():
58
+ if key.startswith("_") or key in skip_keys:
59
+ continue
60
+ if value is not None:
61
+ value_str = str(value)
62
+ if truncate and len(value_str) > 200:
63
+ value_str = value_str[:200] + "..."
64
+ print(f" {key}: {value_str}")
65
+
66
+
67
+ def _handle_export(graph_path: Path, result: dict) -> None:
68
+ """Handle optional result export.
69
+
70
+ Args:
71
+ graph_path: Path to the graph YAML file
72
+ result: Graph execution result dict
73
+ """
74
+ from yamlgraph.storage.export import export_result
75
+
76
+ with open(graph_path) as f:
77
+ graph_config = yaml.safe_load(f)
78
+
79
+ export_config = graph_config.get("exports", {})
80
+ if export_config:
81
+ paths = export_result(result, export_config)
82
+ if paths:
83
+ print("\n📁 Exported:")
84
+ for p in paths:
85
+ print(f" {p}")
86
+
87
+
88
+ def cmd_graph_run(args: Namespace) -> None:
89
+ """Run any graph with provided variables.
90
+
91
+ Usage:
92
+ yamlgraph graph run graphs/yamlgraph.yaml --var topic=AI --var style=casual
93
+ """
94
+ from yamlgraph.graph_loader import load_and_compile
95
+
96
+ graph_path = Path(args.graph_path)
97
+
98
+ if not graph_path.exists():
99
+ print(f"❌ Graph file not found: {graph_path}")
100
+ sys.exit(1)
101
+
102
+ # Parse variables
103
+ try:
104
+ initial_state = parse_vars(args.var)
105
+ except ValueError as e:
106
+ print(f"❌ {e}")
107
+ sys.exit(1)
108
+
109
+ print(f"\n🚀 Running graph: {graph_path.name}")
110
+ if initial_state:
111
+ print(f" Variables: {initial_state}")
112
+ print()
113
+
114
+ try:
115
+ graph = load_and_compile(str(graph_path))
116
+ app = graph.compile()
117
+
118
+ # Add thread_id if provided
119
+ config = {}
120
+ if args.thread:
121
+ config["configurable"] = {"thread_id": args.thread}
122
+ initial_state["thread_id"] = args.thread
123
+
124
+ result = app.invoke(initial_state, config=config if config else None)
125
+
126
+ _display_result(result, truncate=not getattr(args, "full", False))
127
+
128
+ if args.export:
129
+ _handle_export(graph_path, result)
130
+
131
+ print()
132
+
133
+ except Exception as e:
134
+ print(f"❌ Error: {e}")
135
+ sys.exit(1)
136
+
137
+
138
+ def cmd_graph_list(args: Namespace) -> None:
139
+ """List available graphs in graphs/ directory."""
140
+ graphs_dir = Path("graphs")
141
+
142
+ if not graphs_dir.exists():
143
+ print("❌ graphs/ directory not found")
144
+ return
145
+
146
+ yaml_files = sorted(graphs_dir.glob("*.yaml"))
147
+
148
+ if not yaml_files:
149
+ print("No graphs found in graphs/")
150
+ return
151
+
152
+ print(f"\n📋 Available graphs ({len(yaml_files)}):\n")
153
+
154
+ for path in yaml_files:
155
+ try:
156
+ with open(path) as f:
157
+ config = yaml.safe_load(f)
158
+ description = config.get("description", "")
159
+ print(f" {path.name}")
160
+ if description:
161
+ print(f" {description[:60]}")
162
+ except Exception:
163
+ print(f" {path.name} (invalid)")
164
+
165
+ print()
166
+
167
+
168
+ def cmd_graph_info(args: Namespace) -> None:
169
+ """Show information about a graph."""
170
+ graph_path = Path(args.graph_path)
171
+
172
+ if not graph_path.exists():
173
+ print(f"❌ Graph file not found: {graph_path}")
174
+ sys.exit(1)
175
+
176
+ try:
177
+ with open(graph_path) as f:
178
+ config = yaml.safe_load(f)
179
+
180
+ name = config.get("name", graph_path.stem)
181
+ description = config.get("description", "No description")
182
+ state_class = config.get("state_class", "default")
183
+ nodes = config.get("nodes", {})
184
+ edges = config.get("edges", [])
185
+
186
+ print(f"\n📊 Graph: {name}")
187
+ print(f" {description}")
188
+ print(f"\n State: {state_class}")
189
+
190
+ # Show nodes
191
+ print(f"\n Nodes ({len(nodes)}):")
192
+ for node_name, node_config in nodes.items():
193
+ node_type = node_config.get("type", "prompt")
194
+ print(f" - {node_name} ({node_type})")
195
+
196
+ # Show edges
197
+ print(f"\n Edges ({len(edges)}):")
198
+ for edge in edges:
199
+ from_node = edge.get("from", "?")
200
+ to_node = edge.get("to", "?")
201
+ condition = edge.get("condition", "")
202
+ if condition:
203
+ print(f" {from_node} → {to_node} (conditional)")
204
+ else:
205
+ print(f" {from_node} → {to_node}")
206
+
207
+ # Show required inputs if defined
208
+ inputs = config.get("inputs", {})
209
+ if inputs:
210
+ print(f"\n Inputs ({len(inputs)}):")
211
+ for input_name, input_config in inputs.items():
212
+ required = input_config.get("required", False)
213
+ default = input_config.get("default", None)
214
+ req_str = " (required)" if required else f" (default: {default})"
215
+ print(f" --var {input_name}=<value>{req_str}")
216
+
217
+ print()
218
+
219
+ except Exception as e:
220
+ print(f"❌ Error reading graph: {e}")
221
+ sys.exit(1)
222
+
223
+
224
+ def cmd_graph_mermaid(args: Namespace) -> None:
225
+ """Generate Mermaid diagram from a graph."""
226
+ graph_path = Path(args.graph_path)
227
+
228
+ if not graph_path.exists():
229
+ print(f"❌ Graph file not found: {graph_path}")
230
+ sys.exit(1)
231
+
232
+ try:
233
+ with open(graph_path) as f:
234
+ config = yaml.safe_load(f)
235
+
236
+ mermaid = generate_mermaid(config)
237
+
238
+ if hasattr(args, "output") and args.output:
239
+ output_path = Path(args.output)
240
+ output_path.write_text(mermaid)
241
+ print(f"✅ Mermaid diagram written to {output_path}")
242
+ else:
243
+ print(mermaid)
244
+
245
+ except Exception as e:
246
+ print(f"❌ Error generating mermaid: {e}")
247
+ sys.exit(1)
248
+
249
+
250
+ def generate_mermaid(config: dict) -> str:
251
+ """Generate Mermaid flowchart from graph config.
252
+
253
+ Args:
254
+ config: Parsed YAML graph configuration
255
+
256
+ Returns:
257
+ Mermaid flowchart diagram as string
258
+ """
259
+ lines = ["```mermaid", "flowchart TD"]
260
+
261
+ nodes = config.get("nodes", {})
262
+ edges = config.get("edges", [])
263
+
264
+ # Node shapes by type
265
+ node_shapes = {
266
+ "llm": ("[", "]"), # Rectangle
267
+ "prompt": ("[", "]"),
268
+ "tool": ("[[", "]]"), # Subroutine
269
+ "agent": ("{{", "}}"), # Hexagon
270
+ "map": ("[/", "/]"), # Parallelogram
271
+ "subgraph": ("[[", "]]"), # Subroutine (composition)
272
+ "router": ("{", "}"), # Diamond-ish
273
+ "interrupt": ("(", ")"), # Stadium
274
+ "passthrough": ("([", "])"), # Stadium
275
+ }
276
+
277
+ # Define nodes with shapes
278
+ for node_name, node_config in nodes.items():
279
+ node_type = node_config.get("type", "llm")
280
+ left, right = node_shapes.get(node_type, ("[", "]"))
281
+ # Escape quotes in node name for display
282
+ display_name = node_name.replace('"', "'")
283
+ lines.append(f' {node_name}{left}"{display_name}"{right}')
284
+
285
+ lines.append("")
286
+
287
+ # Define edges
288
+ for edge in edges:
289
+ from_node = edge.get("from", "START")
290
+ to_node = edge.get("to", "END")
291
+ condition = edge.get("condition")
292
+ edge_type = edge.get("type")
293
+
294
+ # Map START/END to special nodes
295
+ from_id = "__start__" if from_node == "START" else from_node
296
+
297
+ # Handle list of targets (conditional routing)
298
+ if isinstance(to_node, list):
299
+ for target in to_node:
300
+ to_id = "__end__" if target == "END" else target
301
+ lines.append(f" {from_id} -.-> {to_id}")
302
+ else:
303
+ to_id = "__end__" if to_node == "END" else to_node
304
+
305
+ if condition:
306
+ # Conditional edge with label
307
+ cond_label = condition if isinstance(condition, str) else "?"
308
+ lines.append(f" {from_id} -->|{cond_label}| {to_id}")
309
+ elif edge_type == "conditional":
310
+ lines.append(f" {from_id} -.-> {to_id}")
311
+ else:
312
+ lines.append(f" {from_id} --> {to_id}")
313
+
314
+ lines.append("```")
315
+ return "\n".join(lines)
316
+
317
+
318
+ def _validate_required_fields(config: dict) -> tuple[list[str], list[str]]:
319
+ """Validate required fields in graph config.
320
+
321
+ Args:
322
+ config: Parsed YAML configuration
323
+
324
+ Returns:
325
+ Tuple of (errors, warnings) lists
326
+ """
327
+ errors = []
328
+ warnings = []
329
+
330
+ if not config.get("name"):
331
+ errors.append("Missing required field: name")
332
+
333
+ if not config.get("nodes"):
334
+ errors.append("Missing required field: nodes")
335
+
336
+ if not config.get("edges"):
337
+ warnings.append("No edges defined")
338
+
339
+ return errors, warnings
340
+
341
+
342
+ def _validate_edges(edges: list[dict], node_names: set[str]) -> list[str]:
343
+ """Validate edge references in graph config.
344
+
345
+ Args:
346
+ edges: List of edge configurations
347
+ node_names: Set of valid node names (including START/END)
348
+
349
+ Returns:
350
+ List of error messages
351
+ """
352
+ errors = []
353
+
354
+ for i, edge in enumerate(edges):
355
+ from_node = edge.get("from", "")
356
+ to_node = edge.get("to", "")
357
+
358
+ if from_node not in node_names:
359
+ errors.append(f"Edge {i + 1}: unknown 'from' node '{from_node}'")
360
+
361
+ # Handle conditional edges where 'to' is a list
362
+ if isinstance(to_node, list):
363
+ for t in to_node:
364
+ if t not in node_names:
365
+ errors.append(f"Edge {i + 1}: unknown 'to' node '{t}'")
366
+ elif to_node not in node_names:
367
+ errors.append(f"Edge {i + 1}: unknown 'to' node '{to_node}'")
368
+
369
+ return errors
370
+
371
+
372
+ def _validate_nodes(nodes: dict) -> list[str]:
373
+ """Validate node configurations.
374
+
375
+ Args:
376
+ nodes: Dict of node_name -> node_config
377
+
378
+ Returns:
379
+ List of warning messages
380
+ """
381
+ warnings = []
382
+
383
+ for node_name, node_config in nodes.items():
384
+ node_type = node_config.get("type", "llm")
385
+ if node_type == "agent" and not node_config.get("tools"):
386
+ warnings.append(f"Node '{node_name}': agent has no tools")
387
+
388
+ return warnings
389
+
390
+
391
+ def _report_validation_result(
392
+ graph_path: Path,
393
+ config: dict,
394
+ errors: list[str],
395
+ warnings: list[str],
396
+ ) -> None:
397
+ """Report validation results and exit appropriately.
398
+
399
+ Args:
400
+ graph_path: Path to the graph file
401
+ config: Parsed graph configuration
402
+ errors: List of error messages
403
+ warnings: List of warning messages
404
+ """
405
+ name = config.get("name", graph_path.stem)
406
+ nodes = config.get("nodes", {})
407
+ edges = config.get("edges", [])
408
+
409
+ if errors:
410
+ print(f"\n❌ {graph_path.name} ({name}) - INVALID\n")
411
+ for err in errors:
412
+ print(f" ✗ {err}")
413
+ for warn in warnings:
414
+ print(f" ⚠ {warn}")
415
+ print()
416
+ sys.exit(1)
417
+ elif warnings:
418
+ print(f"\n⚠️ {graph_path.name} ({name}) - VALID with warnings\n")
419
+ for warn in warnings:
420
+ print(f" ⚠ {warn}")
421
+ print()
422
+ else:
423
+ print(f"\n✅ {graph_path.name} ({name}) - VALID\n")
424
+ print(f" Nodes: {len(nodes)}")
425
+ print(f" Edges: {len(edges)}")
426
+ print()
427
+
428
+
429
+ def cmd_graph_validate(args: Namespace) -> None:
430
+ """Validate a graph YAML file.
431
+
432
+ Checks:
433
+ - File exists and is valid YAML
434
+ - Required fields present (name, nodes, edges)
435
+ - Node references are valid
436
+ - Edge references match existing nodes
437
+ """
438
+ graph_path = Path(args.graph_path)
439
+
440
+ if not graph_path.exists():
441
+ print(f"❌ Graph file not found: {graph_path}")
442
+ sys.exit(1)
443
+
444
+ try:
445
+ with open(graph_path) as f:
446
+ config = yaml.safe_load(f)
447
+
448
+ # Run validations
449
+ errors, warnings = _validate_required_fields(config)
450
+
451
+ nodes = config.get("nodes", {})
452
+ edges = config.get("edges", [])
453
+ node_names = set(nodes.keys()) | {"START", "END"}
454
+
455
+ errors.extend(_validate_edges(edges, node_names))
456
+ warnings.extend(_validate_nodes(nodes))
457
+
458
+ # Report results
459
+ _report_validation_result(graph_path, config, errors, warnings)
460
+
461
+ except yaml.YAMLError as e:
462
+ print(f"❌ Invalid YAML: {e}")
463
+ sys.exit(1)
464
+ except Exception as e:
465
+ print(f"❌ Error validating graph: {e}")
466
+ sys.exit(1)
467
+
468
+
469
+ def cmd_graph_lint(args: Namespace) -> None:
470
+ """Lint graph YAML files for issues.
471
+
472
+ Checks:
473
+ - Missing state declarations for variables
474
+ - Undefined tool references
475
+ - Missing prompt files
476
+ - Unreachable nodes
477
+ - Invalid node types
478
+ """
479
+ total_errors = 0
480
+ total_warnings = 0
481
+
482
+ for graph_path_str in args.graph_path:
483
+ graph_path = Path(graph_path_str)
484
+
485
+ if not graph_path.exists():
486
+ print(f"❌ Graph file not found: {graph_path}")
487
+ total_errors += 1
488
+ continue
489
+
490
+ try:
491
+ result = lint_graph(graph_path, WORKING_DIR)
492
+
493
+ errors = [i for i in result.issues if i.severity == "error"]
494
+ warnings = [i for i in result.issues if i.severity == "warning"]
495
+
496
+ if result.valid and not warnings:
497
+ print(f"✅ {graph_path.name} - No issues found")
498
+ else:
499
+ status = "❌" if errors else "⚠️"
500
+ print(f"{status} {graph_path.name}")
501
+
502
+ for issue in result.issues:
503
+ icon = "❌" if issue.severity == "error" else "⚠"
504
+ print(f" {icon} [{issue.code}] {issue.message}")
505
+ if issue.fix:
506
+ print(f" Fix: {issue.fix}")
507
+
508
+ total_errors += len(errors)
509
+ total_warnings += len(warnings)
510
+
511
+ except Exception as e:
512
+ print(f"❌ Error linting {graph_path}: {e}")
513
+ total_errors += 1
514
+
515
+ # Summary
516
+ print()
517
+ if total_errors == 0 and total_warnings == 0:
518
+ print("✅ All graphs passed linting")
519
+ else:
520
+ print(f"Found {total_errors} error(s) and {total_warnings} warning(s)")
521
+ if total_errors > 0:
522
+ sys.exit(1)
523
+
524
+
525
+ def cmd_graph_dispatch(args: Namespace) -> None:
526
+ """Dispatch to graph subcommands."""
527
+ if args.graph_command == "run":
528
+ cmd_graph_run(args)
529
+ elif args.graph_command == "list":
530
+ cmd_graph_list(args)
531
+ elif args.graph_command == "info":
532
+ cmd_graph_info(args)
533
+ elif args.graph_command == "validate":
534
+ cmd_graph_validate(args)
535
+ elif args.graph_command == "lint":
536
+ cmd_graph_lint(args)
537
+ elif args.graph_command == "mermaid":
538
+ cmd_graph_mermaid(args)
539
+ else:
540
+ print(f"Unknown graph command: {args.graph_command}")
541
+ sys.exit(1)
@@ -0,0 +1,37 @@
1
+ """CLI validation functions.
2
+
3
+ Provides argument validation for CLI commands.
4
+ """
5
+
6
+ from yamlgraph.config import MAX_WORD_COUNT, MIN_WORD_COUNT
7
+ from yamlgraph.utils.sanitize import sanitize_topic
8
+
9
+
10
+ def validate_run_args(args) -> bool:
11
+ """Validate and sanitize run command arguments.
12
+
13
+ Args:
14
+ args: Parsed arguments namespace
15
+
16
+ Returns:
17
+ True if valid, False otherwise (prints error message)
18
+ """
19
+ # Sanitize topic
20
+ result = sanitize_topic(args.topic)
21
+ if not result.is_safe:
22
+ for warning in result.warnings:
23
+ print(f"❌ {warning}")
24
+ return False
25
+
26
+ # Update args with sanitized value
27
+ args.topic = result.value
28
+
29
+ # Print any warnings (e.g., truncation)
30
+ for warning in result.warnings:
31
+ print(f"⚠️ {warning}")
32
+
33
+ if args.word_count < MIN_WORD_COUNT or args.word_count > MAX_WORD_COUNT:
34
+ print(f"❌ Word count must be between {MIN_WORD_COUNT} and {MAX_WORD_COUNT}")
35
+ return False
36
+
37
+ return True
yamlgraph/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Centralized configuration for the yamlgraph package.
2
+
3
+ Provides paths, settings, and environment configuration
4
+ used across all modules.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ # Package root (yamlgraph/ directory)
13
+ PACKAGE_ROOT = Path(__file__).parent
14
+
15
+ # Working directory (where the user runs the CLI from)
16
+ WORKING_DIR = Path.cwd()
17
+
18
+ # Load environment variables from current working directory
19
+ # This ensures .env is found where the user runs yamlgraph, not in site-packages
20
+ load_dotenv(WORKING_DIR / ".env")
21
+
22
+ # Directory paths (relative to working directory)
23
+ PROMPTS_DIR = WORKING_DIR / "prompts"
24
+ GRAPHS_DIR = WORKING_DIR / "graphs"
25
+ OUTPUTS_DIR = WORKING_DIR / "outputs"
26
+ DATABASE_PATH = OUTPUTS_DIR / "yamlgraph.db"
27
+
28
+ # Default graph configuration
29
+ DEFAULT_GRAPH = GRAPHS_DIR / "yamlgraph.yaml"
30
+
31
+ # LLM Configuration
32
+ DEFAULT_TEMPERATURE = 0.7
33
+ DEFAULT_MAX_TOKENS = 4096
34
+
35
+ # Default models per provider (override with {PROVIDER}_MODEL env var)
36
+ DEFAULT_MODELS = {
37
+ "anthropic": os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5"),
38
+ "mistral": os.getenv("MISTRAL_MODEL", "mistral-large-latest"),
39
+ "openai": os.getenv("OPENAI_MODEL", "gpt-4o"),
40
+ }
41
+
42
+ # Retry Configuration
43
+ MAX_RETRIES = int(os.getenv("LLM_MAX_RETRIES", "3"))
44
+ RETRY_BASE_DELAY = float(os.getenv("LLM_RETRY_DELAY", "1.0")) # seconds
45
+ RETRY_MAX_DELAY = float(os.getenv("LLM_RETRY_MAX_DELAY", "30.0")) # seconds
46
+
47
+ # CLI Constraints - configurable via environment
48
+ MAX_TOPIC_LENGTH = int(os.getenv("YAMLGRAPH_MAX_TOPIC_LENGTH", "500"))
49
+ MAX_WORD_COUNT = int(os.getenv("YAMLGRAPH_MAX_WORD_COUNT", "5000"))
50
+ MIN_WORD_COUNT = int(os.getenv("YAMLGRAPH_MIN_WORD_COUNT", "50"))
51
+
52
+ # Valid styles - can be extended via environment (comma-separated)
53
+ _default_styles = "informative,casual,technical"
54
+ VALID_STYLES = tuple(os.getenv("YAMLGRAPH_VALID_STYLES", _default_styles).split(","))
55
+
56
+ # Input Sanitization Patterns
57
+ # Characters that could be used for prompt injection
58
+ DANGEROUS_PATTERNS = [
59
+ "ignore previous",
60
+ "ignore above",
61
+ "disregard",
62
+ "forget everything",
63
+ "new instructions",
64
+ "system:",
65
+ "<|", # Token delimiters
66
+ "|>",
67
+ ]