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,454 @@
1
+ """Function node factory for creating function-based pipeline nodes."""
2
+
3
+ import asyncio
4
+ import importlib
5
+ import inspect
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any, get_type_hints
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from hexdag.core.domain.dag import NodeSpec
13
+ from hexdag.core.logging import get_logger
14
+ from hexdag.core.protocols import is_schema_type
15
+
16
+ from .base_node_factory import BaseNodeFactory
17
+ from .mapped_input import MappedInput
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class FunctionNode(BaseNodeFactory):
23
+ """Simple factory for creating function-based nodes with optional Pydantic validation.
24
+
25
+ Function nodes are highly dynamic - the function itself defines configuration via its
26
+ signature and parameters. No static Config class needed (follows YAGNI principle).
27
+ All configuration is passed dynamically through __call__() parameters.
28
+ """
29
+
30
+ def __call__(
31
+ self,
32
+ name: str,
33
+ fn: Callable[..., Any] | str,
34
+ input_schema: dict[str, Any] | type[BaseModel] | None = None,
35
+ output_schema: dict[str, Any] | type[BaseModel] | None = None,
36
+ deps: list[str] | None = None,
37
+ input_mapping: dict[str, str] | None = None,
38
+ unpack_input: bool = False,
39
+ **kwargs: Any,
40
+ ) -> NodeSpec:
41
+ """Create a NodeSpec for a function-based node.
42
+
43
+ Args:
44
+ ----
45
+ name: Node name
46
+ fn: Function to execute (callable or module path string like 'mymodule.myfunc')
47
+ input_schema: Input schema for validation (if None, inferred from function)
48
+ output_schema: Output schema for validation (if None, inferred from function)
49
+ deps: List of dependency node names
50
+ input_mapping: Optional field mapping dict {target_field: "source.path"}
51
+ unpack_input: If True, unpack input_mapping fields as individual **kwargs
52
+ to the function instead of passing as single input_data object.
53
+ This allows functions with signatures like `fn(load_id, rate, *, db=None)`
54
+ instead of `fn(input_data, *, db=None)`.
55
+ **kwargs: Additional parameters
56
+
57
+ Returns
58
+ -------
59
+ NodeSpec
60
+ Complete node specification ready for execution
61
+ """
62
+ # Resolve function from string path if needed
63
+ resolved_fn = self._resolve_function(fn)
64
+
65
+ # Validate function can be used properly
66
+ self._validate_function(resolved_fn, unpack_input=unpack_input)
67
+
68
+ if input_mapping is not None:
69
+ kwargs["input_mapping"] = input_mapping
70
+
71
+ if unpack_input:
72
+ kwargs["unpack_input"] = True
73
+
74
+ if input_mapping and not input_schema:
75
+ # Auto-generate Pydantic model from field mapping
76
+ input_schema = MappedInput.create_model(
77
+ f"{name}MappedInput",
78
+ input_mapping,
79
+ dependency_models=None, # Could enhance with dependency introspection
80
+ )
81
+
82
+ # Infer schemas from function annotations if not provided
83
+ if input_schema is None or output_schema is None:
84
+ inferred_input, inferred_output = self._infer_schemas_from_function(resolved_fn)
85
+ input_schema = input_schema or inferred_input
86
+ output_schema = output_schema or inferred_output
87
+
88
+ # For basic types like dict, list, str, etc., use them directly
89
+
90
+ if isinstance(input_schema, type) and input_schema.__name__ in {
91
+ "dict",
92
+ "list",
93
+ "str",
94
+ "int",
95
+ "float",
96
+ "bool",
97
+ }:
98
+ input_model: type[BaseModel] | type | None = input_schema
99
+ else:
100
+ input_model = self.create_pydantic_model(f"{name}Input", input_schema)
101
+
102
+ if isinstance(output_schema, type) and output_schema.__name__ in {
103
+ "dict",
104
+ "list",
105
+ "str",
106
+ "int",
107
+ "float",
108
+ "bool",
109
+ }:
110
+ output_model: type[BaseModel] | type | None = output_schema
111
+ else:
112
+ output_model = self.create_pydantic_model(f"{name}Output", output_schema)
113
+
114
+ wrapped_fn = self._create_wrapped_function(
115
+ name, resolved_fn, input_model, output_model, unpack_input=unpack_input
116
+ )
117
+
118
+ # Extract framework-level parameters from kwargs
119
+ framework = self.extract_framework_params(kwargs)
120
+
121
+ return NodeSpec(
122
+ name=name,
123
+ fn=wrapped_fn,
124
+ in_model=input_model,
125
+ out_model=output_model,
126
+ deps=frozenset(deps or []),
127
+ params=kwargs,
128
+ timeout=framework["timeout"],
129
+ max_retries=framework["max_retries"],
130
+ when=framework["when"],
131
+ )
132
+
133
+ def _create_wrapped_function(
134
+ self,
135
+ name: str,
136
+ fn: Callable[..., Any],
137
+ input_model: type[BaseModel] | type | None,
138
+ output_model: type[BaseModel] | type | None,
139
+ *,
140
+ unpack_input: bool = False,
141
+ ) -> Callable[..., Any]:
142
+ """Create a simple wrapped function with explicit port handling.
143
+
144
+ Parameters
145
+ ----------
146
+ name : str
147
+ Node name for logging
148
+ fn : Callable[..., Any]
149
+ The function to wrap
150
+ input_model : type[BaseModel] | type | None
151
+ Input model for validation
152
+ output_model : type[BaseModel] | type | None
153
+ Output model for validation
154
+ unpack_input : bool, default=False
155
+ If True, unpack input_data fields as individual **kwargs to the function
156
+ instead of passing as single input_data object.
157
+
158
+ Returns
159
+ -------
160
+ Callable[..., Any]
161
+ Wrapped function that handles orchestrator integration
162
+ """
163
+ # Analyze function signature once
164
+ sig = inspect.signature(fn)
165
+ accepts_kwargs = any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values())
166
+ param_names = set(sig.parameters.keys())
167
+
168
+ # Get function name for logging
169
+ fn_name = getattr(fn, "__name__", "anonymous")
170
+
171
+ async def wrapped_fn(input_data: Any, **ports: Any) -> Any:
172
+ """Execute function with explicit port handling."""
173
+ node_logger = logger.bind(node=name, node_type="function_node")
174
+
175
+ # Log input details at debug level
176
+ if isinstance(input_data, dict):
177
+ node_logger.debug(
178
+ "Input received",
179
+ input_keys=list(input_data.keys()),
180
+ input_key_count=len(input_data),
181
+ )
182
+ else:
183
+ node_logger.debug(
184
+ "Input received",
185
+ input_type=type(input_data).__name__,
186
+ )
187
+
188
+ # Log execution start
189
+ node_logger.info("Executing function", fn_name=fn_name)
190
+
191
+ start_time = time.perf_counter()
192
+
193
+ # Prepare function call arguments
194
+ if accepts_kwargs:
195
+ # Function accepts **kwargs, pass all ports
196
+ call_kwargs = ports
197
+ else:
198
+ # Function has specific parameters, only pass ports that match parameter names
199
+ call_kwargs = {k: v for k, v in ports.items() if k in param_names}
200
+
201
+ try:
202
+ # Execute function (handle both sync and async)
203
+ if unpack_input:
204
+ # Unpack input_data fields as individual kwargs
205
+ # This allows functions with signatures like fn(load_id, rate, *, db=None)
206
+ if isinstance(input_data, dict):
207
+ unpacked_kwargs = {**input_data, **call_kwargs}
208
+ elif isinstance(input_data, BaseModel):
209
+ unpacked_kwargs = {**input_data.model_dump(), **call_kwargs}
210
+ else:
211
+ # Fallback: try to convert to dict
212
+ unpacked_kwargs = {**vars(input_data), **call_kwargs}
213
+
214
+ if asyncio.iscoroutinefunction(fn):
215
+ result = await fn(**unpacked_kwargs)
216
+ else:
217
+ result = fn(**unpacked_kwargs)
218
+ else:
219
+ # Standard behavior: pass input_data as first positional argument
220
+ if asyncio.iscoroutinefunction(fn):
221
+ result = await fn(input_data, **call_kwargs)
222
+ else:
223
+ result = fn(input_data, **call_kwargs)
224
+
225
+ # Log successful completion
226
+ duration_ms = (time.perf_counter() - start_time) * 1000
227
+ node_logger.debug(
228
+ "Function completed",
229
+ fn_name=fn_name,
230
+ duration_ms=f"{duration_ms:.2f}",
231
+ output_type=type(result).__name__,
232
+ )
233
+
234
+ return result
235
+
236
+ except Exception as e:
237
+ # Log failure
238
+ duration_ms = (time.perf_counter() - start_time) * 1000
239
+ node_logger.error(
240
+ "Function failed",
241
+ fn_name=fn_name,
242
+ duration_ms=f"{duration_ms:.2f}",
243
+ error=str(e),
244
+ error_type=type(e).__name__,
245
+ )
246
+ raise
247
+
248
+ # Preserve function metadata
249
+ wrapped_fn.__name__ = getattr(fn, "__name__", f"wrapped_{name}")
250
+ wrapped_fn.__doc__ = getattr(fn, "__doc__", f"Wrapped function: {name}")
251
+
252
+ return wrapped_fn
253
+
254
+ def _resolve_function(self, fn: Callable[..., Any] | str) -> Callable[..., Any]:
255
+ """Resolve function from callable or module path string.
256
+
257
+ Args
258
+ ----
259
+ fn: Function (callable) or module path string (e.g., 'mymodule.myfunc')
260
+
261
+ Returns
262
+ -------
263
+ Callable[..., Any]
264
+ The resolved callable function
265
+
266
+ Raises
267
+ ------
268
+ TypeError
269
+ If fn is not a callable or string
270
+ ValueError
271
+ If string path cannot be resolved to a callable
272
+ """
273
+ if callable(fn):
274
+ return fn
275
+
276
+ # At this point, fn should be a string based on type hints
277
+ # But we validate at runtime for safety
278
+ if not isinstance(fn, str): # pyright: ignore[reportUnnecessaryIsInstance]
279
+ raise TypeError(
280
+ f"Expected a callable function or string module path, got {type(fn).__name__}"
281
+ )
282
+
283
+ # Parse module path string
284
+ if "." not in fn:
285
+ raise ValueError(f"Function path must be in format 'module.function', got: {fn}")
286
+
287
+ # Split the module path
288
+ module_path, func_name = fn.rsplit(".", 1)
289
+
290
+ try:
291
+ module = importlib.import_module(module_path)
292
+ resolved_fn = getattr(module, func_name)
293
+
294
+ if not callable(resolved_fn):
295
+ raise ValueError(
296
+ f"Resolved '{fn}' is not callable (got {type(resolved_fn).__name__})"
297
+ )
298
+
299
+ return resolved_fn # type: ignore[no-any-return]
300
+
301
+ except ImportError as e:
302
+ raise ValueError(f"Could not import module from function path '{fn}': {e}") from e
303
+ except AttributeError as e:
304
+ raise ValueError(f"Function '{func_name}' not found in module '{module_path}'") from e
305
+
306
+ def _validate_function(self, fn: Callable[..., Any], *, unpack_input: bool = False) -> None:
307
+ """Validate that function can be properly wrapped.
308
+
309
+ Args
310
+ ----
311
+ fn: Function to validate
312
+ unpack_input: If True, function receives unpacked kwargs instead of input_data
313
+
314
+ Raises
315
+ ------
316
+ ValueError
317
+ If function cannot be used
318
+ """
319
+ sig = inspect.signature(fn)
320
+ params = list(sig.parameters.values())
321
+
322
+ if unpack_input:
323
+ # With unpack_input, function receives fields as **kwargs
324
+ # It can have any signature, including just **kwargs
325
+ # No validation needed - fields are passed as keyword arguments
326
+ return
327
+
328
+ # Standard mode: function needs at least one parameter to receive input_data
329
+ if not params:
330
+ raise ValueError("Function must have at least one parameter to receive input_data")
331
+
332
+ first_param = params[0]
333
+ if first_param.kind == inspect.Parameter.VAR_KEYWORD:
334
+ raise ValueError("First parameter cannot be **kwargs - need parameter for input_data")
335
+
336
+ def _infer_schemas_from_function(
337
+ self, fn: Callable[..., Any]
338
+ ) -> tuple[type[BaseModel] | None, type[BaseModel] | None]:
339
+ """Infer input and output schemas from function type annotations.
340
+
341
+ Args
342
+ ----
343
+ fn: Function to analyze
344
+
345
+ Returns
346
+ -------
347
+ tuple[type[BaseModel] | None, type[BaseModel] | None]
348
+ Tuple of (input_schema, output_schema) where each can be None if not inferrable
349
+ """
350
+ try:
351
+ type_hints = get_type_hints(fn)
352
+ sig = inspect.signature(fn)
353
+
354
+ # Infer input schema from first parameter (skip 'self' if present)
355
+ input_schema = None
356
+ if params := list(sig.parameters.values()):
357
+ # Skip 'self' parameter if present
358
+ first_param = (
359
+ params[0]
360
+ if params[0].name != "self"
361
+ else (params[1] if len(params) > 1 else None)
362
+ )
363
+ if first_param and first_param.name in type_hints:
364
+ param_type = type_hints[first_param.name]
365
+ if is_schema_type(param_type):
366
+ input_schema = param_type
367
+
368
+ # Infer output schema from return annotation
369
+ output_schema = None
370
+ if "return" in type_hints:
371
+ return_type = type_hints["return"]
372
+ if is_schema_type(return_type):
373
+ output_schema = return_type
374
+
375
+ return input_schema, output_schema
376
+
377
+ except (TypeError, AttributeError, ValueError):
378
+ # If type hints are malformed or unavailable, skip inference
379
+ return None, None
380
+
381
+ @staticmethod
382
+ def create_passthrough_mapping(fields: list[str]) -> dict[str, str]:
383
+ """Create a passthrough mapping where field names are unchanged.
384
+
385
+ Args:
386
+ ----
387
+ fields: List of field names to pass through
388
+
389
+ Returns
390
+ -------
391
+ dict[str, str]
392
+ Mapping dict {field: field} for each field
393
+ """
394
+ return {field: field for field in fields}
395
+
396
+ @staticmethod
397
+ def create_rename_mapping(mapping: dict[str, str]) -> dict[str, str]:
398
+ """Create a simple rename mapping.
399
+
400
+ Args:
401
+ ----
402
+ mapping: Dict of {new_name: old_name}
403
+
404
+ Returns
405
+ -------
406
+ dict[str, str]
407
+ The mapping dict as-is (for consistency with other methods)
408
+ """
409
+ return mapping
410
+
411
+ @staticmethod
412
+ def create_prefixed_mapping(fields: list[str], source_node: str, prefix: str) -> dict[str, str]:
413
+ """Create a mapping with prefixed field names.
414
+
415
+ Args:
416
+ ----
417
+ fields: List of field names to map
418
+ source_node: Name of the source node
419
+ prefix: Prefix to add to field names
420
+
421
+ Returns
422
+ -------
423
+ dict[str, str]
424
+ Mapping dict {prefix_field: source_node.field}
425
+ """
426
+ return {f"{prefix}{field}": f"{source_node}.{field}" for field in fields}
427
+
428
+ def with_input_mapping(self, node: NodeSpec, input_mapping: dict[str, str]) -> NodeSpec:
429
+ """Enhance an existing node with input mapping.
430
+
431
+ Args:
432
+ ----
433
+ node: The node to enhance
434
+ input_mapping: The input mapping to apply
435
+
436
+ Returns
437
+ -------
438
+ NodeSpec
439
+ New NodeSpec with the input mapping applied
440
+ """
441
+ new_params = dict(node.params) if node.params else {}
442
+ new_params["input_mapping"] = input_mapping
443
+
444
+ return NodeSpec(
445
+ name=node.name,
446
+ fn=node.fn,
447
+ in_model=node.in_model,
448
+ out_model=node.out_model,
449
+ deps=node.deps,
450
+ params=new_params,
451
+ timeout=node.timeout,
452
+ max_retries=node.max_retries,
453
+ when=node.when,
454
+ )