hexdag 0.5.0.dev1__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 (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,517 @@
1
+ """YAML-defined macros for declarative pipeline composition.
2
+
3
+ This module enables defining macros entirely in YAML without Python code.
4
+ YAML macros provide the same capabilities as Python macros but with a
5
+ declarative syntax suitable for version control and team collaboration.
6
+
7
+ Architecture:
8
+ YamlMacro - ConfigurableMacro subclass that expands from YAML definition
9
+ Parameter validation - Same as nodes (Pydantic-based)
10
+ Template expansion - Jinja2 for node generation
11
+ Output mapping - Same as Python macros (DirectedGraph)
12
+
13
+ Examples
14
+ --------
15
+ YAML macro definition::
16
+
17
+ apiVersion: hexdag/v1
18
+ kind: Macro
19
+ metadata:
20
+ name: retry_workflow
21
+ description: Retry a node with exponential backoff
22
+ parameters:
23
+ - name: max_retries
24
+ type: int
25
+ default: 3
26
+ - name: base_delay
27
+ type: float
28
+ default: 1.0
29
+ nodes:
30
+ - kind: function_node
31
+ metadata:
32
+ name: "{{name}}_attempt"
33
+ spec:
34
+ fn: "{{fn}}"
35
+ max_retries: "{{max_retries}}"
36
+
37
+ YAML macro usage::
38
+
39
+ nodes:
40
+ - kind: macro_invocation
41
+ metadata:
42
+ name: api_call
43
+ spec:
44
+ macro: user:retry_workflow
45
+ config:
46
+ max_retries: 5
47
+ inputs:
48
+ fn: "myapp.api.fetch_data"
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from typing import Any
54
+
55
+ from jinja2 import ChainableUndefined, Environment, TemplateSyntaxError, UndefinedError
56
+ from pydantic import BaseModel, Field, field_validator
57
+
58
+ from hexdag.core.configurable import ConfigurableMacro, MacroConfig
59
+ from hexdag.core.domain.dag import DirectedGraph
60
+ from hexdag.core.logging import get_logger
61
+
62
+ logger = get_logger(__name__)
63
+
64
+
65
+ class PreserveUndefined(ChainableUndefined):
66
+ """Custom Jinja2 Undefined that preserves template syntax for undefined variables.
67
+
68
+ This allows partial template rendering where:
69
+ - Known variables (from macro context) are replaced
70
+ - Unknown variables (runtime references) are preserved as {{var}}
71
+ """
72
+
73
+ def __str__(self) -> str:
74
+ """Return the original template syntax for undefined variables."""
75
+ return f"{{{{{self._undefined_name}}}}}"
76
+
77
+ def __getattr__(self, name: str) -> Any:
78
+ """Handle attribute access on undefined variables.
79
+
80
+ This preserves the full path for dotted references like {{node.output}}.
81
+ """
82
+ if name.startswith("_"):
83
+ # Internal Jinja2 attributes - use parent implementation
84
+ return super().__getattr__(name)
85
+
86
+ # Create a new PreserveUndefined with the full path
87
+ return self.__class__(
88
+ name=f"{self._undefined_name}.{name}",
89
+ exc=self._undefined_exception,
90
+ )
91
+
92
+
93
+ class YamlMacroParameterSpec(BaseModel):
94
+ """Schema for a single YAML macro parameter.
95
+
96
+ Attributes
97
+ ----------
98
+ name : str
99
+ Parameter name
100
+ type : str
101
+ Python type name (e.g., "str", "int", "list", "dict")
102
+ description : str | None
103
+ Parameter description for documentation
104
+ required : bool
105
+ Whether parameter is required (default: False)
106
+ default : Any
107
+ Default value if not provided
108
+ enum : list[Any] | None
109
+ Valid values for enumeration types
110
+ """
111
+
112
+ name: str
113
+ type: str = "str"
114
+ description: str | None = None
115
+ required: bool = False
116
+ default: Any = None
117
+ enum: list[Any] | None = None
118
+
119
+ @field_validator("type")
120
+ @classmethod
121
+ def validate_type(cls, v: str) -> str:
122
+ """Validate that type is a recognized Python type."""
123
+ # Support common types and union syntax
124
+ valid_base_types = {"str", "int", "float", "bool", "list", "dict", "Any"}
125
+ # Split on | for union types
126
+ types = [t.strip() for t in v.split("|")]
127
+ for t in types:
128
+ if t not in valid_base_types:
129
+ raise ValueError(
130
+ f"Invalid type '{t}'. Must be one of: {', '.join(valid_base_types)}"
131
+ )
132
+ return v
133
+
134
+
135
+ class YamlMacroConfig(MacroConfig):
136
+ """Configuration for YAML-defined macros.
137
+
138
+ This config is dynamically generated from the YAML macro definition.
139
+ It stores the macro structure (parameters, nodes, outputs) that will
140
+ be expanded when the macro is invoked.
141
+
142
+ Attributes
143
+ ----------
144
+ macro_name : str
145
+ Name of the macro
146
+ macro_description : str | None
147
+ Description from metadata
148
+ parameters : list[YamlMacroParameterSpec]
149
+ Parameter definitions
150
+ nodes : list[dict[str, Any]]
151
+ Node templates (will be rendered with Jinja2)
152
+ outputs : dict[str, str] | None
153
+ Output mappings (optional, like Python macros)
154
+ """
155
+
156
+ macro_name: str
157
+ macro_description: str | None = None
158
+ parameters: list[YamlMacroParameterSpec] = Field(default_factory=list)
159
+ nodes: list[dict[str, Any]] = Field(default_factory=list)
160
+ outputs: dict[str, str] | None = None
161
+
162
+
163
+ class YamlMacro(ConfigurableMacro):
164
+ """Macro defined entirely in YAML.
165
+
166
+ This class provides the runtime implementation for YAML macro expansion.
167
+ It's instantiated by MacroDefinitionPlugin when processing `kind: Macro`
168
+ declarations in YAML files.
169
+
170
+ The expansion process:
171
+ 1. Validate provided config against parameter definitions
172
+ 2. Build Jinja2 context from parameters + inputs
173
+ 3. Render node templates
174
+ 4. Build DirectedGraph from rendered nodes
175
+ 5. Return expanded graph
176
+
177
+ Examples
178
+ --------
179
+ YAML macro definition::
180
+
181
+ apiVersion: hexdag/v1
182
+ kind: Macro
183
+ metadata:
184
+ name: hitl_decision
185
+ description: Human-in-the-loop decision point
186
+ parameters:
187
+ - name: mode
188
+ type: str
189
+ default: human
190
+ enum: [human, auto]
191
+ - name: timeout
192
+ type: int
193
+ default: 60
194
+ nodes:
195
+ - kind: conditional
196
+ metadata:
197
+ name: "{{name}}_route"
198
+ spec:
199
+ condition: "{{mode == 'human'}}"
200
+ true_branch: "{{name}}_human"
201
+ false_branch: "{{name}}_auto"
202
+
203
+ Python invocation::
204
+
205
+ from hexdag.core.resolver import resolve
206
+
207
+ MacroClass = resolve("myapp.macros.HitlDecisionMacro")
208
+ macro = MacroClass()
209
+ graph = macro.expand(
210
+ instance_name="approval",
211
+ inputs={"mode": "auto"},
212
+ dependencies=["validator"]
213
+ )
214
+
215
+ YAML invocation::
216
+
217
+ nodes:
218
+ - kind: macro_invocation
219
+ metadata:
220
+ name: approval
221
+ spec:
222
+ macro: user:hitl_decision
223
+ config:
224
+ mode: auto
225
+ dependencies: [validator]
226
+ """
227
+
228
+ Config = YamlMacroConfig
229
+
230
+ def __init__(self, **kwargs: Any) -> None:
231
+ """Initialize YAML macro from config.
232
+
233
+ Parameters
234
+ ----------
235
+ **kwargs : Any
236
+ Configuration matching YamlMacroConfig schema
237
+ """
238
+ super().__init__(**kwargs)
239
+
240
+ # Create Jinja2 environment for template rendering
241
+ # Use PreserveUndefined to allow partial rendering:
242
+ # - Macro variables ({{name}}, {{param}}) are replaced
243
+ # - Runtime variables ({{node.output}}) are preserved
244
+ # Note: autoescape=False is intentional - we're processing YAML, not HTML
245
+ self.jinja_env = Environment(
246
+ autoescape=False, # nosec B701 - YAML processing, not HTML
247
+ undefined=PreserveUndefined,
248
+ keep_trailing_newline=True,
249
+ )
250
+
251
+ # Build parameter schema for validation
252
+ self._param_schema = self._build_param_schema()
253
+
254
+ def _build_param_schema(self) -> dict[str, YamlMacroParameterSpec]:
255
+ """Build parameter schema from config for fast lookup."""
256
+ config: YamlMacroConfig = self.config # type: ignore[assignment]
257
+ return {param.name: param for param in config.parameters}
258
+
259
+ def expand(
260
+ self,
261
+ instance_name: str,
262
+ inputs: dict[str, Any],
263
+ dependencies: list[str],
264
+ ) -> DirectedGraph:
265
+ """Expand YAML macro into a DirectedGraph.
266
+
267
+ Parameters
268
+ ----------
269
+ instance_name : str
270
+ Unique name for this macro instance (used in templates as {{name}})
271
+ inputs : dict[str, Any]
272
+ Input values for macro parameters (merged with defaults)
273
+ dependencies : list[str]
274
+ External node names this macro depends on
275
+
276
+ Returns
277
+ -------
278
+ DirectedGraph
279
+ Expanded graph with rendered nodes
280
+ """
281
+ config: YamlMacroConfig = self.config # type: ignore[assignment]
282
+
283
+ # Step 1: Validate and normalize inputs
284
+ validated_inputs = self._validate_and_normalize_inputs(inputs)
285
+
286
+ # Step 2: Build Jinja2 context
287
+ context = self._build_template_context(instance_name, validated_inputs, dependencies)
288
+
289
+ # Step 3: Render node templates
290
+ rendered_nodes = self._render_node_templates(config.nodes, context)
291
+
292
+ # Step 4: Build DirectedGraph from rendered nodes
293
+ graph = self._build_graph_from_nodes(rendered_nodes)
294
+
295
+ logger.info(
296
+ f"✅ Expanded YAML macro '{config.macro_name}' as '{instance_name}' "
297
+ f"({len(graph.nodes)} nodes)"
298
+ )
299
+
300
+ return graph
301
+
302
+ def _validate_and_normalize_inputs(self, inputs: dict[str, Any]) -> dict[str, Any]:
303
+ """Validate inputs against parameter schema and apply defaults.
304
+
305
+ Parameters
306
+ ----------
307
+ inputs : dict[str, Any]
308
+ Provided input values
309
+
310
+ Returns
311
+ -------
312
+ dict[str, Any]
313
+ Validated inputs with defaults applied
314
+
315
+ Raises
316
+ ------
317
+ ValueError
318
+ If required parameters are missing or enum validation fails
319
+ """
320
+ result = {}
321
+ config: YamlMacroConfig = self.config # type: ignore[assignment]
322
+
323
+ for param in config.parameters:
324
+ param_name = param.name
325
+
326
+ # Check if value provided
327
+ if param_name in inputs:
328
+ value = inputs[param_name]
329
+
330
+ # Enum validation
331
+ if param.enum is not None and value not in param.enum:
332
+ raise ValueError(
333
+ f"Parameter '{param_name}' must be one of {param.enum}, got '{value}'"
334
+ )
335
+
336
+ result[param_name] = value
337
+
338
+ elif param.required:
339
+ # Required but not provided
340
+ raise ValueError(
341
+ f"Required parameter '{param_name}' not provided for macro "
342
+ f"'{config.macro_name}'"
343
+ )
344
+
345
+ elif param.default is not None:
346
+ # Apply default
347
+ result[param_name] = param.default
348
+
349
+ return result
350
+
351
+ def _build_template_context(
352
+ self,
353
+ instance_name: str,
354
+ validated_inputs: dict[str, Any],
355
+ dependencies: list[str],
356
+ ) -> dict[str, Any]:
357
+ """Build Jinja2 context for template rendering.
358
+
359
+ Parameters
360
+ ----------
361
+ instance_name : str
362
+ Macro instance name
363
+ validated_inputs : dict[str, Any]
364
+ Validated parameter values
365
+ dependencies : list[str]
366
+ External dependencies
367
+
368
+ Returns
369
+ -------
370
+ dict[str, Any]
371
+ Template context with special variables + parameters
372
+ """
373
+ return {
374
+ # Special variables
375
+ "name": instance_name,
376
+ "dependencies": dependencies,
377
+ # All validated parameters
378
+ **validated_inputs,
379
+ }
380
+
381
+ def _render_node_templates(
382
+ self,
383
+ node_templates: list[dict[str, Any]],
384
+ context: dict[str, Any],
385
+ ) -> list[dict[str, Any]]:
386
+ """Render node templates with Jinja2.
387
+
388
+ Parameters
389
+ ----------
390
+ node_templates : list[dict[str, Any]]
391
+ Node template definitions
392
+ context : dict[str, Any]
393
+ Template rendering context
394
+
395
+ Returns
396
+ -------
397
+ list[dict[str, Any]]
398
+ Rendered node configurations
399
+
400
+ Raises
401
+ ------
402
+ ValueError
403
+ If template rendering fails
404
+ """
405
+ rendered = []
406
+
407
+ for i, node_template in enumerate(node_templates):
408
+ try:
409
+ rendered_node = self._render_dict_recursive(node_template, context)
410
+ rendered.append(rendered_node)
411
+ except (TemplateSyntaxError, UndefinedError) as e:
412
+ config: YamlMacroConfig = self.config # type: ignore[assignment]
413
+ raise ValueError(
414
+ f"Failed to render node template {i} in macro '{config.macro_name}': {e}"
415
+ ) from e
416
+
417
+ return rendered
418
+
419
+ def _render_dict_recursive(self, obj: Any, context: dict[str, Any]) -> Any:
420
+ """Recursively render Jinja2 templates in nested structures.
421
+
422
+ Uses PreserveUndefined to enable partial rendering:
423
+ - Macro variables ({{name}}, {{param}}) are replaced with actual values
424
+ - Runtime variables ({{node.output}}) are preserved as-is for execution time
425
+
426
+ Example:
427
+ Template: "Macro: {{name}}, Runtime: {{other_node.result}}"
428
+ Context: {"name": "my_instance"}
429
+ Result: "Macro: my_instance, Runtime: {{other_node.result}}"
430
+
431
+ Parameters
432
+ ----------
433
+ obj : Any
434
+ Object to render (dict, list, str, or primitive)
435
+ context : dict[str, Any]
436
+ Template context
437
+
438
+ Returns
439
+ -------
440
+ Any
441
+ Rendered object with same structure
442
+ """
443
+ if isinstance(obj, str):
444
+ # Render string template (undefined vars are preserved)
445
+ if "{{" in obj or "{%" in obj:
446
+ template = self.jinja_env.from_string(obj)
447
+ return template.render(context)
448
+ return obj
449
+
450
+ if isinstance(obj, dict):
451
+ # Render dict values recursively
452
+ return {k: self._render_dict_recursive(v, context) for k, v in obj.items()}
453
+
454
+ if isinstance(obj, list):
455
+ # Render list items recursively
456
+ return [self._render_dict_recursive(item, context) for item in obj]
457
+
458
+ # Primitives (int, float, bool, None)
459
+ return obj
460
+
461
+ def _build_graph_from_nodes(self, rendered_nodes: list[dict[str, Any]]) -> DirectedGraph:
462
+ """Build DirectedGraph from rendered node configurations.
463
+
464
+ This uses the same YamlPipelineBuilder logic to build nodes,
465
+ ensuring consistency with regular YAML pipelines.
466
+
467
+ Parameters
468
+ ----------
469
+ rendered_nodes : list[dict[str, Any]]
470
+ Rendered node configurations
471
+
472
+ Returns
473
+ -------
474
+ DirectedGraph
475
+ Graph with all nodes added
476
+
477
+ Raises
478
+ ------
479
+ ValueError
480
+ If node building fails
481
+ """
482
+ # Import here to avoid circular dependency
483
+ from hexdag.core.pipeline_builder.yaml_builder import NodeEntityPlugin, YamlPipelineBuilder
484
+
485
+ # Create temporary builder for node construction
486
+ builder = YamlPipelineBuilder()
487
+ graph = DirectedGraph()
488
+
489
+ # Use NodeEntityPlugin to build each node
490
+ node_plugin = NodeEntityPlugin(builder)
491
+
492
+ for node_config in rendered_nodes:
493
+ # Validate it's a node (not another macro_invocation)
494
+ if node_config.get("kind") == "macro_invocation":
495
+ raise ValueError(
496
+ "YAML macros cannot contain nested macro_invocations. "
497
+ "Use Python macros for composition."
498
+ )
499
+
500
+ if not node_plugin.can_handle(node_config):
501
+ kind = node_config.get("kind", "unknown")
502
+ raise ValueError(f"Invalid node kind in YAML macro: {kind}")
503
+
504
+ # Build node using existing infrastructure
505
+ node_spec = node_plugin.build(node_config, builder, graph)
506
+ graph += node_spec
507
+
508
+ return graph
509
+
510
+ def __repr__(self) -> str:
511
+ """String representation for debugging."""
512
+ config: YamlMacroConfig = self.config # type: ignore[assignment]
513
+ return (
514
+ f"YamlMacro(name='{config.macro_name}', "
515
+ f"parameters={len(config.parameters)}, "
516
+ f"nodes={len(config.nodes)})"
517
+ )