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,201 @@
1
+ """Static data node for returning constant output.
2
+
3
+ .. deprecated::
4
+ DataNode is deprecated. Use ExpressionNode directly instead.
5
+ DataNode already delegates to ExpressionNode internally.
6
+
7
+ This module provides a DataNode factory for creating nodes that return
8
+ static data without requiring Python functions. Useful for terminal
9
+ nodes like rejection actions or static configuration.
10
+
11
+ DataNode now delegates to ExpressionNode internally, supporting both
12
+ static output and template syntax ({{variable}}).
13
+
14
+ Examples
15
+ --------
16
+ Basic usage in Python::
17
+
18
+ from hexdag.builtin.nodes import DataNode
19
+
20
+ node_factory = DataNode()
21
+ node = node_factory(
22
+ name="reject_locked",
23
+ output={"action": "REJECTED", "reason": "Load already has winner locked"}
24
+ )
25
+
26
+ YAML pipeline usage::
27
+
28
+ - kind: data_node
29
+ metadata:
30
+ name: reject_locked
31
+ spec:
32
+ output:
33
+ action: "REJECTED"
34
+ reason: "Load already has winner locked"
35
+
36
+ With template syntax::
37
+
38
+ - kind: data_node
39
+ metadata:
40
+ name: welcome_message
41
+ spec:
42
+ output:
43
+ message: "Welcome {{user.name}}!"
44
+ type: "greeting"
45
+ """
46
+
47
+ import warnings
48
+ from typing import Any
49
+
50
+ from hexdag.core.domain.dag import NodeSpec
51
+ from hexdag.core.logging import get_logger
52
+
53
+ from .base_node_factory import BaseNodeFactory
54
+ from .expression_node import ExpressionNode, _is_template
55
+
56
+ logger = get_logger(__name__)
57
+
58
+
59
+ def _value_to_expression(value: Any) -> str:
60
+ """Convert a value to an expression string.
61
+
62
+ For strings without templates, wraps in quotes.
63
+ For strings with templates, passes through.
64
+ For other types, uses repr().
65
+
66
+ Parameters
67
+ ----------
68
+ value : Any
69
+ The value to convert
70
+
71
+ Returns
72
+ -------
73
+ str
74
+ An expression string that evaluates to the original value
75
+ """
76
+ if isinstance(value, str):
77
+ if _is_template(value):
78
+ # Template syntax - pass through for PromptTemplate rendering
79
+ return value
80
+ # Static string - wrap as literal expression
81
+ return repr(value)
82
+ if isinstance(value, bool):
83
+ # Bool must come before int since bool is subclass of int
84
+ return repr(value)
85
+ if isinstance(value, (int, float)):
86
+ return repr(value)
87
+ if value is None:
88
+ return "None"
89
+ # For complex types (dict, list), use repr
90
+ # Note: This has limitations for nested dicts with templates
91
+ return repr(value)
92
+
93
+
94
+ class DataNode(BaseNodeFactory):
95
+ """Static data node factory that returns constant output.
96
+
97
+ .. deprecated::
98
+ DataNode is deprecated. Use ExpressionNode directly instead.
99
+ This node already delegates to ExpressionNode internally.
100
+
101
+ This node type eliminates the need for trivial Python functions
102
+ that simply return static dictionaries. The output is defined
103
+ declaratively in the YAML configuration.
104
+
105
+ Internally delegates to ExpressionNode for unified template/expression
106
+ handling. Supports {{variable}} template syntax for dynamic values.
107
+
108
+ The node ignores any input data and always returns the configured
109
+ output. Dependencies can still be specified to control execution
110
+ order in the DAG.
111
+
112
+ The YAML schema for this node is auto-generated from the ``__call__`` signature
113
+ and docstrings using ``SchemaGenerator``.
114
+
115
+ Examples
116
+ --------
117
+ >>> factory = DataNode()
118
+ >>> node = factory(
119
+ ... name="static_response",
120
+ ... output={"status": "OK", "code": 200}
121
+ ... )
122
+ >>> node.name
123
+ 'static_response'
124
+
125
+ With dependencies::
126
+
127
+ >>> node = factory(
128
+ ... name="after_validation",
129
+ ... output={"result": "validated"},
130
+ ... deps=["validator"]
131
+ ... )
132
+ >>> "validator" in node.deps
133
+ True
134
+
135
+ With templates::
136
+
137
+ >>> node = factory(
138
+ ... name="greeting",
139
+ ... output={"message": "Hello {{name}}!"},
140
+ ... deps=["user_lookup"]
141
+ ... )
142
+ """
143
+
144
+ # Schema is auto-generated from __call__ signature by SchemaGenerator
145
+
146
+ def __call__(
147
+ self,
148
+ name: str,
149
+ output: dict[str, Any],
150
+ deps: list[str] | None = None,
151
+ **kwargs: Any,
152
+ ) -> NodeSpec:
153
+ """Create a NodeSpec for a data node.
154
+
155
+ Parameters
156
+ ----------
157
+ name : str
158
+ Node name (must be unique within the pipeline)
159
+ output : dict[str, Any]
160
+ Output data to return. Values can be:
161
+ - Static values (strings, numbers, bools, etc.)
162
+ - Template strings using {{variable}} syntax
163
+ deps : list[str] | None, optional
164
+ List of dependency node names for execution ordering
165
+ **kwargs : Any
166
+ Additional parameters (when, timeout, etc.)
167
+
168
+ Returns
169
+ -------
170
+ NodeSpec
171
+ Complete node specification ready for execution
172
+
173
+ Examples
174
+ --------
175
+ >>> factory = DataNode()
176
+ >>> node = factory(
177
+ ... name="reject_locked",
178
+ ... output={"action": "REJECTED", "reason": "Load locked"}
179
+ ... )
180
+ >>> node.name
181
+ 'reject_locked'
182
+ """
183
+ warnings.warn(
184
+ "DataNode is deprecated. Use ExpressionNode directly instead.",
185
+ DeprecationWarning,
186
+ stacklevel=2,
187
+ )
188
+
189
+ # Convert output values to expressions
190
+ expressions: dict[str, str] = {}
191
+ for key, value in output.items():
192
+ expressions[key] = _value_to_expression(value)
193
+
194
+ # Delegate to ExpressionNode
195
+ return ExpressionNode()(
196
+ name=name,
197
+ expressions=expressions,
198
+ output_fields=list(output.keys()),
199
+ deps=deps,
200
+ **kwargs,
201
+ )
@@ -0,0 +1,487 @@
1
+ """ExpressionNode factory for creating expression-based computation nodes.
2
+
3
+ This module provides a node type that computes values using safe AST-based
4
+ expressions, eliminating the need for boilerplate Python code when performing
5
+ calculations and transformations in YAML pipelines.
6
+
7
+ Similar to n8n's "Set Node" (Edit Fields), ExpressionNode is designed for
8
+ data transformation and computation. It also supports merge strategies for
9
+ aggregating outputs from multiple upstream dependency nodes.
10
+
11
+ Examples
12
+ --------
13
+ Basic usage in YAML::
14
+
15
+ - kind: expression_node
16
+ metadata:
17
+ name: calculate_discount
18
+ spec:
19
+ input_mapping:
20
+ price: "product.price"
21
+ quantity: "order.quantity"
22
+ expressions:
23
+ subtotal: "price * quantity"
24
+ discount: "0.1 if quantity > 10 else 0"
25
+ total: "subtotal * (1 - discount)"
26
+ output_fields: [total, discount]
27
+
28
+ Financial calculations with Decimal::
29
+
30
+ - kind: expression_node
31
+ metadata:
32
+ name: calculate_counter
33
+ spec:
34
+ input_mapping:
35
+ offered_rate: "extract_offer.rate"
36
+ rate_floor: "get_context.load.rate_floor"
37
+ counter_count: "get_context.negotiation.counter_count"
38
+ expressions:
39
+ discount: "Decimal('0.10') if counter_count == 0 else Decimal('0.03')"
40
+ floor_val: "Decimal(str(rate_floor or 0))"
41
+ counter_amount: "float(max(Decimal(str(offered_rate)) * (1 - discount), floor_val))"
42
+ output_fields: [counter_amount]
43
+
44
+ Template syntax for string formatting::
45
+
46
+ - kind: expression_node
47
+ metadata:
48
+ name: format_response
49
+ spec:
50
+ input_mapping:
51
+ name: "user.name"
52
+ quantity: "order.quantity"
53
+ expressions:
54
+ # Template syntax for strings (detected by {{ }})
55
+ message: "{{name}} ordered {{quantity}} items"
56
+ greeting: "Hello {{name}}!"
57
+ # Expression syntax for computation
58
+ total: "price * quantity"
59
+ output_fields: [message, greeting, total]
60
+
61
+ Merge strategies for multi-dependency aggregation::
62
+
63
+ # Collect scores from multiple nodes into a list
64
+ - kind: expression_node
65
+ metadata:
66
+ name: collect_scores
67
+ spec:
68
+ merge_strategy: list
69
+ extract_field: score
70
+ dependencies: [scorer_1, scorer_2, scorer_3]
71
+
72
+ # Calculate average score using reduce
73
+ - kind: expression_node
74
+ metadata:
75
+ name: average_score
76
+ spec:
77
+ merge_strategy: reduce
78
+ extract_field: score
79
+ reducer: "statistics.mean"
80
+ dependencies: [scorer_1, scorer_2, scorer_3]
81
+
82
+ # Get first successful result (fallback pattern)
83
+ - kind: expression_node
84
+ metadata:
85
+ name: get_result
86
+ spec:
87
+ merge_strategy: first
88
+ dependencies: [primary, fallback, cache]
89
+ """
90
+
91
+ from collections.abc import Callable
92
+ from typing import Any, Literal
93
+
94
+ from hexdag.builtin.nodes.base_node_factory import BaseNodeFactory
95
+ from hexdag.core.domain.dag import NodeSpec
96
+ from hexdag.core.expression_parser import evaluate_expression
97
+ from hexdag.core.logging import get_logger
98
+ from hexdag.core.orchestration.prompt.template import PromptTemplate
99
+ from hexdag.core.resolver import resolve_function
100
+
101
+ logger = get_logger(__name__)
102
+
103
+ # Type alias for merge strategies
104
+ MergeStrategy = Literal["dict", "list", "first", "last", "reduce"]
105
+
106
+
107
+ def _is_template(expr: str) -> bool:
108
+ """Check if expression uses template syntax (contains {{ }})."""
109
+ return "{{" in expr and "}}" in expr
110
+
111
+
112
+ def _extract_field(value: Any, field: str | None) -> Any:
113
+ """Extract a field from a value if specified.
114
+
115
+ Parameters
116
+ ----------
117
+ value : Any
118
+ The value to extract from
119
+ field : str | None
120
+ Dot-notation field path to extract (e.g., "result.score")
121
+
122
+ Returns
123
+ -------
124
+ Any
125
+ The extracted field value, or the original value if no field specified
126
+ """
127
+ if field is None:
128
+ return value
129
+
130
+ result = value
131
+ for part in field.split("."):
132
+ if isinstance(result, dict):
133
+ result = result.get(part)
134
+ elif hasattr(result, part):
135
+ result = getattr(result, part)
136
+ else:
137
+ return None
138
+ return result
139
+
140
+
141
+ def _apply_merge_strategy(
142
+ input_data: dict[str, Any],
143
+ strategy: MergeStrategy,
144
+ field_path: str | None,
145
+ reducer: Callable[[list[Any]], Any] | None,
146
+ dep_order: list[str],
147
+ ) -> Any:
148
+ """Apply merge strategy to multi-dependency input.
149
+
150
+ Parameters
151
+ ----------
152
+ input_data : dict[str, Any]
153
+ Dict of {node_name: result} from dependencies
154
+ strategy : MergeStrategy
155
+ The merge strategy to apply
156
+ field_path : str | None
157
+ Field to extract from each result before merging (dot notation)
158
+ reducer : Callable | None
159
+ Reducer function for 'reduce' strategy
160
+ dep_order : list[str]
161
+ Ordered list of dependency names for consistent ordering
162
+
163
+ Returns
164
+ -------
165
+ Any
166
+ The merged result
167
+ """
168
+ # Get values in dependency order, extracting field if specified
169
+ values = []
170
+ for dep in dep_order:
171
+ if dep in input_data:
172
+ val = _extract_field(input_data[dep], field_path)
173
+ values.append(val)
174
+
175
+ match strategy:
176
+ case "dict":
177
+ # Return as-is (passthrough) or with field extraction
178
+ if field_path:
179
+ return {
180
+ dep: _extract_field(input_data[dep], field_path)
181
+ for dep in dep_order
182
+ if dep in input_data
183
+ }
184
+ return dict(input_data)
185
+
186
+ case "list":
187
+ return values
188
+
189
+ case "first":
190
+ for val in values:
191
+ if val is not None:
192
+ return val
193
+ return None
194
+
195
+ case "last":
196
+ for val in reversed(values):
197
+ if val is not None:
198
+ return val
199
+ return None
200
+
201
+ case "reduce":
202
+ if reducer is None:
203
+ raise ValueError("reducer is required for 'reduce' strategy")
204
+ # Filter out None values
205
+ non_none_values = [v for v in values if v is not None]
206
+ if not non_none_values:
207
+ return None
208
+ return reducer(non_none_values)
209
+
210
+ case _:
211
+ raise ValueError(f"Unknown merge strategy: {strategy}")
212
+
213
+
214
+ class ExpressionNode(BaseNodeFactory):
215
+ """Node factory for computing values using safe AST-based expressions.
216
+
217
+ ExpressionNode eliminates dict packing/unpacking boilerplate by:
218
+ 1. Auto-extracting input fields via input_mapping (handled by orchestrator)
219
+ 2. Evaluating chained expressions in definition order
220
+ 3. Filtering output to specified fields
221
+
222
+ It also supports merge strategies for aggregating outputs from multiple
223
+ upstream dependency nodes:
224
+ - dict: Return {node_name: result, ...} (default passthrough)
225
+ - list: Return [result1, result2, ...] in dependency order
226
+ - first: Return first non-None result
227
+ - last: Return last non-None result
228
+ - reduce: Apply reducer function (e.g., statistics.mean)
229
+
230
+ This node uses the same safe expression parser as CompositeNode, but
231
+ returns computed values instead of routing decisions.
232
+
233
+ See Also
234
+ --------
235
+ CompositeNode : For control flow (loops, conditionals)
236
+ FunctionNode : For complex logic requiring full Python functions
237
+ """
238
+
239
+ # Schema is auto-generated from __call__ signature by SchemaGenerator
240
+
241
+ def __call__(
242
+ self,
243
+ name: str,
244
+ expressions: dict[str, str] | None = None,
245
+ input_mapping: dict[str, str] | None = None,
246
+ output_fields: list[str] | None = None,
247
+ deps: list[str] | None = None,
248
+ # Merge strategy parameters
249
+ merge_strategy: MergeStrategy | None = None,
250
+ reducer: str | Callable[[list[Any]], Any] | None = None,
251
+ extract_field: str | None = None,
252
+ **kwargs: Any,
253
+ ) -> NodeSpec:
254
+ """Create an ExpressionNode for computing values or merging dependencies.
255
+
256
+ Parameters
257
+ ----------
258
+ name : str
259
+ Node name (unique identifier in the pipeline)
260
+ expressions : dict[str, str] | None
261
+ Mapping of {variable_name: expression_string}.
262
+ Expressions are evaluated in definition order and can reference:
263
+ - Input fields from input_mapping
264
+ - Earlier computed variables
265
+ - Whitelisted functions (len, max, min, Decimal, etc.)
266
+ Optional when using merge_strategy.
267
+ input_mapping : dict[str, str] | None
268
+ Field extraction mapping {local_name: "source_node.field_path"}.
269
+ Handled by the orchestrator's ExecutionCoordinator before node runs.
270
+ output_fields : list[str] | None
271
+ Fields to include in output dict. If None, all computed expressions
272
+ are returned.
273
+ deps : list[str] | None
274
+ Dependency node names (for DAG ordering)
275
+ merge_strategy : MergeStrategy | None
276
+ Strategy for merging multiple dependency outputs:
277
+ - "dict": Return {node_name: result} passthrough (default for multi-dep)
278
+ - "list": Return [result1, result2, ...] in dependency order
279
+ - "first": Return first non-None result
280
+ - "last": Return last non-None result
281
+ - "reduce": Apply reducer function to values
282
+ reducer : str | Callable | None
283
+ Module path (e.g., "statistics.mean") or callable for 'reduce' strategy.
284
+ The function receives a list of values and returns a single result.
285
+ extract_field : str | None
286
+ Field to extract from each dependency result before merging.
287
+ Uses dot notation (e.g., "result.score").
288
+ **kwargs : Any
289
+ Additional parameters passed to NodeSpec
290
+
291
+ Returns
292
+ -------
293
+ NodeSpec
294
+ Configured node specification ready for execution
295
+
296
+ Examples
297
+ --------
298
+ Programmatic usage for expressions::
299
+
300
+ node = ExpressionNode()(
301
+ name="calculate_total",
302
+ expressions={
303
+ "subtotal": "price * quantity",
304
+ "tax": "subtotal * 0.08",
305
+ "total": "subtotal + tax",
306
+ },
307
+ input_mapping={
308
+ "price": "product.price",
309
+ "quantity": "order.quantity",
310
+ },
311
+ output_fields=["total"],
312
+ deps=["product", "order"],
313
+ )
314
+
315
+ Programmatic usage for merging::
316
+
317
+ node = ExpressionNode()(
318
+ name="average_score",
319
+ merge_strategy="reduce",
320
+ extract_field="score",
321
+ reducer="statistics.mean",
322
+ deps=["scorer_1", "scorer_2", "scorer_3"],
323
+ )
324
+
325
+ Notes
326
+ -----
327
+ ValueError may be raised at runtime if an expression fails to evaluate
328
+ or references undefined variables.
329
+ """
330
+ # Validate: either expressions or merge_strategy must be provided
331
+ if expressions is None and merge_strategy is None:
332
+ raise ValueError("Either 'expressions' or 'merge_strategy' must be provided")
333
+
334
+ # Validate: reducer is required for reduce strategy
335
+ if merge_strategy == "reduce" and reducer is None:
336
+ raise ValueError("'reducer' is required when merge_strategy='reduce'")
337
+
338
+ # Store input_mapping in params for orchestrator to handle
339
+ if input_mapping is not None:
340
+ kwargs["input_mapping"] = input_mapping
341
+
342
+ # Resolve reducer if it's a string module path (e.g., "statistics.mean")
343
+ resolved_reducer: Callable[[list[Any]], Any] | None = None
344
+ if reducer is not None:
345
+ resolved_reducer = resolve_function(reducer) if isinstance(reducer, str) else reducer
346
+
347
+ # Capture for closure
348
+ _expressions = expressions or {}
349
+ _output_fields = output_fields or list(_expressions.keys()) if _expressions else None
350
+ _merge_strategy = merge_strategy
351
+ _reducer = resolved_reducer
352
+ _extract_field = extract_field
353
+ _dep_order = list(deps or [])
354
+
355
+ async def expression_fn(input_data: Any, **ports: Any) -> dict[str, Any] | Any:
356
+ """Evaluate expressions and/or apply merge strategy.
357
+
358
+ Parameters
359
+ ----------
360
+ input_data : Any
361
+ Input data (typically dict after input_mapping is applied)
362
+ **ports : Any
363
+ Injected ports (memory, llm, etc.) - usually unused for expressions
364
+
365
+ Returns
366
+ -------
367
+ dict[str, Any] | Any
368
+ Computed values filtered to output_fields, or merged result
369
+ """
370
+ node_logger = logger.bind(node=name, node_type="expression_node")
371
+
372
+ # Apply merge strategy first if specified
373
+ if _merge_strategy is not None and isinstance(input_data, dict):
374
+ node_logger.info(
375
+ "Applying merge strategy",
376
+ strategy=_merge_strategy,
377
+ extract_field=_extract_field,
378
+ dep_count=len(_dep_order),
379
+ )
380
+ merged = _apply_merge_strategy(
381
+ input_data=input_data,
382
+ strategy=_merge_strategy,
383
+ field_path=_extract_field,
384
+ reducer=_reducer,
385
+ dep_order=_dep_order,
386
+ )
387
+
388
+ # If no expressions, return merged result directly
389
+ if not _expressions:
390
+ node_logger.info(
391
+ "Merge complete (no expressions)",
392
+ result_type=type(merged).__name__,
393
+ )
394
+ return {"result": merged}
395
+
396
+ # Otherwise, make merged result available for expressions
397
+ input_data = merged if isinstance(merged, dict) else {"merged": merged}
398
+
399
+ node_logger.info(
400
+ "Evaluating expressions",
401
+ expression_count=len(_expressions),
402
+ output_fields=_output_fields,
403
+ )
404
+
405
+ # Build context from input data
406
+ context: dict[str, Any] = {}
407
+ if isinstance(input_data, dict):
408
+ context.update(input_data)
409
+ elif input_data is not None:
410
+ # Non-dict input - make it available as '_input'
411
+ context["_input"] = input_data
412
+
413
+ # Evaluate expressions in definition order (supports chaining)
414
+ for var_name, expr in _expressions.items():
415
+ try:
416
+ # Check if expression uses template syntax ({{ }})
417
+ if _is_template(expr):
418
+ # Use PromptTemplate for string rendering
419
+ template = PromptTemplate(expr)
420
+ value = template.render(**context)
421
+ node_logger.debug(
422
+ "Template rendered",
423
+ variable=var_name,
424
+ template=expr,
425
+ result_type=type(value).__name__,
426
+ )
427
+ else:
428
+ # Use expression parser for computation
429
+ value = evaluate_expression(expr, context, state={})
430
+ node_logger.debug(
431
+ "Expression evaluated",
432
+ variable=var_name,
433
+ expression=expr,
434
+ result_type=type(value).__name__,
435
+ )
436
+ context[var_name] = value
437
+ except Exception as e:
438
+ node_logger.error(
439
+ "Expression/template evaluation failed",
440
+ variable=var_name,
441
+ expression=expr,
442
+ error=str(e),
443
+ error_type=type(e).__name__,
444
+ )
445
+ raise ValueError(
446
+ f"Expression '{var_name}' failed: {e}\n"
447
+ f" Expression: {expr}\n"
448
+ f" Available context: {list(context.keys())}"
449
+ ) from e
450
+
451
+ # Filter to output_fields only
452
+ result: dict[str, Any] = {}
453
+ fields_to_output = _output_fields or list(_expressions.keys())
454
+ for field in fields_to_output:
455
+ if field in context:
456
+ result[field] = context[field]
457
+ else:
458
+ node_logger.warning(
459
+ "Output field not found in context",
460
+ field=field,
461
+ available=list(context.keys()),
462
+ )
463
+
464
+ node_logger.info(
465
+ "Expression evaluation complete",
466
+ output_keys=list(result.keys()),
467
+ )
468
+ return result
469
+
470
+ # Preserve function metadata for debugging
471
+ expression_fn.__name__ = f"expression_{name}"
472
+ expression_fn.__doc__ = f"Expression node: {name}"
473
+
474
+ # Extract framework-level parameters from kwargs
475
+ framework = self.extract_framework_params(kwargs)
476
+
477
+ return NodeSpec(
478
+ name=name,
479
+ fn=expression_fn,
480
+ in_model=None, # Accepts any dict input
481
+ out_model=None, # Returns dict output
482
+ deps=frozenset(deps or []),
483
+ params=kwargs,
484
+ timeout=framework["timeout"],
485
+ max_retries=framework["max_retries"],
486
+ when=framework["when"],
487
+ )