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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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
|
+
)
|