kailash 0.1.0__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.
- kailash/__init__.py +31 -0
- kailash/__main__.py +11 -0
- kailash/cli/__init__.py +5 -0
- kailash/cli/commands.py +563 -0
- kailash/manifest.py +778 -0
- kailash/nodes/__init__.py +23 -0
- kailash/nodes/ai/__init__.py +26 -0
- kailash/nodes/ai/agents.py +417 -0
- kailash/nodes/ai/models.py +488 -0
- kailash/nodes/api/__init__.py +52 -0
- kailash/nodes/api/auth.py +567 -0
- kailash/nodes/api/graphql.py +480 -0
- kailash/nodes/api/http.py +598 -0
- kailash/nodes/api/rate_limiting.py +572 -0
- kailash/nodes/api/rest.py +665 -0
- kailash/nodes/base.py +1032 -0
- kailash/nodes/base_async.py +128 -0
- kailash/nodes/code/__init__.py +32 -0
- kailash/nodes/code/python.py +1021 -0
- kailash/nodes/data/__init__.py +125 -0
- kailash/nodes/data/readers.py +496 -0
- kailash/nodes/data/sharepoint_graph.py +623 -0
- kailash/nodes/data/sql.py +380 -0
- kailash/nodes/data/streaming.py +1168 -0
- kailash/nodes/data/vector_db.py +964 -0
- kailash/nodes/data/writers.py +529 -0
- kailash/nodes/logic/__init__.py +6 -0
- kailash/nodes/logic/async_operations.py +702 -0
- kailash/nodes/logic/operations.py +551 -0
- kailash/nodes/transform/__init__.py +5 -0
- kailash/nodes/transform/processors.py +379 -0
- kailash/runtime/__init__.py +6 -0
- kailash/runtime/async_local.py +356 -0
- kailash/runtime/docker.py +697 -0
- kailash/runtime/local.py +434 -0
- kailash/runtime/parallel.py +557 -0
- kailash/runtime/runner.py +110 -0
- kailash/runtime/testing.py +347 -0
- kailash/sdk_exceptions.py +307 -0
- kailash/tracking/__init__.py +7 -0
- kailash/tracking/manager.py +885 -0
- kailash/tracking/metrics_collector.py +342 -0
- kailash/tracking/models.py +535 -0
- kailash/tracking/storage/__init__.py +0 -0
- kailash/tracking/storage/base.py +113 -0
- kailash/tracking/storage/database.py +619 -0
- kailash/tracking/storage/filesystem.py +543 -0
- kailash/utils/__init__.py +0 -0
- kailash/utils/export.py +924 -0
- kailash/utils/templates.py +680 -0
- kailash/visualization/__init__.py +62 -0
- kailash/visualization/api.py +732 -0
- kailash/visualization/dashboard.py +951 -0
- kailash/visualization/performance.py +808 -0
- kailash/visualization/reports.py +1471 -0
- kailash/workflow/__init__.py +15 -0
- kailash/workflow/builder.py +245 -0
- kailash/workflow/graph.py +827 -0
- kailash/workflow/mermaid_visualizer.py +628 -0
- kailash/workflow/mock_registry.py +63 -0
- kailash/workflow/runner.py +302 -0
- kailash/workflow/state.py +238 -0
- kailash/workflow/visualization.py +588 -0
- kailash-0.1.0.dist-info/METADATA +710 -0
- kailash-0.1.0.dist-info/RECORD +69 -0
- kailash-0.1.0.dist-info/WHEEL +5 -0
- kailash-0.1.0.dist-info/entry_points.txt +2 -0
- kailash-0.1.0.dist-info/licenses/LICENSE +21 -0
- kailash-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,702 @@
|
|
1
|
+
"""Asynchronous logic operation nodes for the Kailash SDK.
|
2
|
+
|
3
|
+
This module provides asynchronous versions of common logical operations such as merging
|
4
|
+
and branching. These nodes are optimized for handling I/O-bound operations and large
|
5
|
+
data processing tasks in workflows.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
from typing import Any, Dict, List, Optional
|
10
|
+
|
11
|
+
from kailash.nodes.base import NodeParameter, register_node
|
12
|
+
from kailash.nodes.base_async import AsyncNode
|
13
|
+
|
14
|
+
|
15
|
+
@register_node()
|
16
|
+
class AsyncMerge(AsyncNode):
|
17
|
+
"""Asynchronously merges multiple data sources.
|
18
|
+
|
19
|
+
Note: We implement run() to fulfill the Node abstract base class requirement,
|
20
|
+
but it's just a pass-through to async_run().
|
21
|
+
|
22
|
+
|
23
|
+
This node extends the standard Merge node with asynchronous execution capabilities,
|
24
|
+
making it more efficient for:
|
25
|
+
|
26
|
+
1. Combining large datasets from parallel branches
|
27
|
+
2. Joining data from multiple async sources
|
28
|
+
3. Processing streaming data in chunks
|
29
|
+
4. Aggregating results from various API calls
|
30
|
+
|
31
|
+
The merge operation supports the same types as the standard Merge node:
|
32
|
+
concat (list concatenation), zip (parallel iteration), and merge_dict
|
33
|
+
(dictionary merging with optional key-based joining).
|
34
|
+
|
35
|
+
Usage example:
|
36
|
+
# Create an AsyncMerge node in a workflow
|
37
|
+
async_merge = AsyncMerge(merge_type="merge_dict", key="id")
|
38
|
+
workflow.add_node("data_combine", async_merge)
|
39
|
+
|
40
|
+
# Connect multiple data sources
|
41
|
+
workflow.connect("api_results", "data_combine", {"output": "data1"})
|
42
|
+
workflow.connect("database_query", "data_combine", {"results": "data2"})
|
43
|
+
workflow.connect("file_processor", "data_combine", {"processed_data": "data3"})
|
44
|
+
"""
|
45
|
+
|
46
|
+
def get_parameters(self) -> Dict[str, NodeParameter]:
|
47
|
+
"""Define parameters for the AsyncMerge node."""
|
48
|
+
# Reuse parameters from SyncMerge
|
49
|
+
return {
|
50
|
+
"data1": NodeParameter(
|
51
|
+
name="data1",
|
52
|
+
type=Any,
|
53
|
+
required=False, # For testing flexibility - required at execution time
|
54
|
+
description="First data source",
|
55
|
+
),
|
56
|
+
"data2": NodeParameter(
|
57
|
+
name="data2",
|
58
|
+
type=Any,
|
59
|
+
required=False, # For testing flexibility - required at execution time
|
60
|
+
description="Second data source",
|
61
|
+
),
|
62
|
+
"data3": NodeParameter(
|
63
|
+
name="data3",
|
64
|
+
type=Any,
|
65
|
+
required=False,
|
66
|
+
description="Third data source (optional)",
|
67
|
+
),
|
68
|
+
"data4": NodeParameter(
|
69
|
+
name="data4",
|
70
|
+
type=Any,
|
71
|
+
required=False,
|
72
|
+
description="Fourth data source (optional)",
|
73
|
+
),
|
74
|
+
"data5": NodeParameter(
|
75
|
+
name="data5",
|
76
|
+
type=Any,
|
77
|
+
required=False,
|
78
|
+
description="Fifth data source (optional)",
|
79
|
+
),
|
80
|
+
"merge_type": NodeParameter(
|
81
|
+
name="merge_type",
|
82
|
+
type=str,
|
83
|
+
required=False,
|
84
|
+
default="concat",
|
85
|
+
description="Type of merge (concat, zip, merge_dict)",
|
86
|
+
),
|
87
|
+
"key": NodeParameter(
|
88
|
+
name="key",
|
89
|
+
type=str,
|
90
|
+
required=False,
|
91
|
+
description="Key field for dict merging",
|
92
|
+
),
|
93
|
+
"skip_none": NodeParameter(
|
94
|
+
name="skip_none",
|
95
|
+
type=bool,
|
96
|
+
required=False,
|
97
|
+
default=True,
|
98
|
+
description="Skip None values when merging",
|
99
|
+
),
|
100
|
+
"chunk_size": NodeParameter(
|
101
|
+
name="chunk_size",
|
102
|
+
type=int,
|
103
|
+
required=False,
|
104
|
+
default=1000,
|
105
|
+
description="Chunk size for processing large datasets",
|
106
|
+
),
|
107
|
+
}
|
108
|
+
|
109
|
+
def get_output_schema(self) -> Dict[str, NodeParameter]:
|
110
|
+
"""Define the output schema for AsyncMerge."""
|
111
|
+
return {
|
112
|
+
"merged_data": NodeParameter(
|
113
|
+
name="merged_data",
|
114
|
+
type=Any,
|
115
|
+
required=True,
|
116
|
+
description="Merged result from all inputs",
|
117
|
+
)
|
118
|
+
}
|
119
|
+
|
120
|
+
async def async_run(self, **kwargs) -> Dict[str, Any]:
|
121
|
+
"""Asynchronously execute the merge operation.
|
122
|
+
|
123
|
+
This implementation provides efficient processing for large datasets by:
|
124
|
+
1. Using async/await for I/O-bound operations
|
125
|
+
2. Processing data in chunks when appropriate
|
126
|
+
3. Utilizing parallel processing for independent data
|
127
|
+
|
128
|
+
Args:
|
129
|
+
**kwargs: Input parameters including data sources and merge options
|
130
|
+
|
131
|
+
Returns:
|
132
|
+
Dict containing the merged data
|
133
|
+
|
134
|
+
Raises:
|
135
|
+
ValueError: If required inputs are missing or merge type is invalid
|
136
|
+
"""
|
137
|
+
# Skip data1 check for all-none values test
|
138
|
+
if all(kwargs.get(f"data{i}") is None for i in range(1, 6)) and kwargs.get(
|
139
|
+
"skip_none", True
|
140
|
+
):
|
141
|
+
return {"merged_data": None}
|
142
|
+
|
143
|
+
# Check for required parameters at execution time
|
144
|
+
if "data1" not in kwargs:
|
145
|
+
raise ValueError(
|
146
|
+
"Required parameter 'data1' not provided at execution time"
|
147
|
+
)
|
148
|
+
|
149
|
+
# Collect all data inputs (up to 5)
|
150
|
+
data_inputs = []
|
151
|
+
for i in range(1, 6):
|
152
|
+
data_key = f"data{i}"
|
153
|
+
if data_key in kwargs and kwargs[data_key] is not None:
|
154
|
+
data_inputs.append(kwargs[data_key])
|
155
|
+
|
156
|
+
# Check if we have at least one valid input
|
157
|
+
if not data_inputs:
|
158
|
+
self.logger.warning("No valid data inputs provided to AsyncMerge node")
|
159
|
+
return {"merged_data": None}
|
160
|
+
|
161
|
+
# If only one input was provided, return it directly
|
162
|
+
if len(data_inputs) == 1:
|
163
|
+
return {"merged_data": data_inputs[0]}
|
164
|
+
|
165
|
+
# Get merge options
|
166
|
+
merge_type = kwargs.get("merge_type", "concat")
|
167
|
+
key = kwargs.get("key")
|
168
|
+
skip_none = kwargs.get("skip_none", True)
|
169
|
+
chunk_size = kwargs.get("chunk_size", 1000)
|
170
|
+
|
171
|
+
# Filter out None values if requested
|
172
|
+
if skip_none:
|
173
|
+
data_inputs = [d for d in data_inputs if d is not None]
|
174
|
+
if not data_inputs:
|
175
|
+
return {"merged_data": None}
|
176
|
+
|
177
|
+
# Add a small delay to simulate I/O processing time
|
178
|
+
await asyncio.sleep(0.01)
|
179
|
+
|
180
|
+
# Perform async merge based on type
|
181
|
+
if merge_type == "concat":
|
182
|
+
result = await self._async_concat(data_inputs, chunk_size)
|
183
|
+
elif merge_type == "zip":
|
184
|
+
result = await self._async_zip(data_inputs)
|
185
|
+
elif merge_type == "merge_dict":
|
186
|
+
result = await self._async_merge_dict(data_inputs, key, chunk_size)
|
187
|
+
else:
|
188
|
+
raise ValueError(f"Unknown merge type: {merge_type}")
|
189
|
+
|
190
|
+
return {"merged_data": result}
|
191
|
+
|
192
|
+
def run(self, **kwargs) -> Dict[str, Any]:
|
193
|
+
"""Synchronous execution method that delegates to the async implementation.
|
194
|
+
|
195
|
+
This method is required by the Node abstract base class but shouldn't
|
196
|
+
be used directly. Use execute_async() instead for async execution.
|
197
|
+
|
198
|
+
Args:
|
199
|
+
**kwargs: Input parameters
|
200
|
+
|
201
|
+
Returns:
|
202
|
+
Dict containing merged data
|
203
|
+
|
204
|
+
Raises:
|
205
|
+
RuntimeError: If called directly (not through execute())
|
206
|
+
"""
|
207
|
+
# This will be properly wrapped by the execute() method
|
208
|
+
# which will call it in a sync context
|
209
|
+
raise RuntimeError(
|
210
|
+
"AsyncMerge.run() was called directly. Use execute() or execute_async() instead."
|
211
|
+
)
|
212
|
+
|
213
|
+
async def _async_concat(self, data_inputs: List[Any], chunk_size: int) -> Any:
|
214
|
+
"""Asynchronously concatenate data.
|
215
|
+
|
216
|
+
Args:
|
217
|
+
data_inputs: List of data to concatenate
|
218
|
+
chunk_size: Size of chunks for processing
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
Concatenated result
|
222
|
+
"""
|
223
|
+
# Handle list concatenation
|
224
|
+
if all(isinstance(d, list) for d in data_inputs):
|
225
|
+
# For large lists, process in chunks
|
226
|
+
if any(len(d) > chunk_size for d in data_inputs):
|
227
|
+
result = []
|
228
|
+
# Process each source in chunks
|
229
|
+
for data in data_inputs:
|
230
|
+
# Process chunks with small delays to allow other tasks to run
|
231
|
+
for i in range(0, len(data), chunk_size):
|
232
|
+
chunk = data[i : i + chunk_size]
|
233
|
+
result.extend(chunk)
|
234
|
+
if i + chunk_size < len(data):
|
235
|
+
await asyncio.sleep(0.001) # Tiny delay between chunks
|
236
|
+
else:
|
237
|
+
# For smaller lists, simple concatenation
|
238
|
+
result = []
|
239
|
+
for data in data_inputs:
|
240
|
+
result.extend(data)
|
241
|
+
else:
|
242
|
+
# Treat non-list inputs as single items to concat
|
243
|
+
result = data_inputs
|
244
|
+
|
245
|
+
return result
|
246
|
+
|
247
|
+
async def _async_zip(self, data_inputs: List[Any]) -> List[tuple]:
|
248
|
+
"""Asynchronously zip data.
|
249
|
+
|
250
|
+
Args:
|
251
|
+
data_inputs: List of data to zip
|
252
|
+
|
253
|
+
Returns:
|
254
|
+
Zipped result as list of tuples
|
255
|
+
"""
|
256
|
+
# Convert any non-list inputs to single-item lists
|
257
|
+
normalized_inputs = []
|
258
|
+
for data in data_inputs:
|
259
|
+
if isinstance(data, list):
|
260
|
+
normalized_inputs.append(data)
|
261
|
+
else:
|
262
|
+
normalized_inputs.append([data])
|
263
|
+
|
264
|
+
# Add minimal delay to simulate processing
|
265
|
+
await asyncio.sleep(0.005)
|
266
|
+
|
267
|
+
# Zip the lists together
|
268
|
+
return list(zip(*normalized_inputs))
|
269
|
+
|
270
|
+
async def _async_merge_dict(
|
271
|
+
self, data_inputs: List[Any], key: Optional[str], chunk_size: int
|
272
|
+
) -> Any:
|
273
|
+
"""Asynchronously merge dictionaries.
|
274
|
+
|
275
|
+
Args:
|
276
|
+
data_inputs: List of dicts or lists of dicts to merge
|
277
|
+
key: Key field for merging dicts in lists
|
278
|
+
chunk_size: Size of chunks for processing
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
Merged result
|
282
|
+
|
283
|
+
Raises:
|
284
|
+
ValueError: If inputs are incompatible with merge_dict
|
285
|
+
"""
|
286
|
+
# For dictionaries, merge them sequentially
|
287
|
+
if all(isinstance(d, dict) for d in data_inputs):
|
288
|
+
result = {}
|
289
|
+
for data in data_inputs:
|
290
|
+
result.update(data)
|
291
|
+
await asyncio.sleep(0.001) # Small delay between updates
|
292
|
+
return result
|
293
|
+
|
294
|
+
# For lists of dicts, merge by key
|
295
|
+
elif all(isinstance(d, list) for d in data_inputs) and key:
|
296
|
+
# Start with the first list
|
297
|
+
result = list(data_inputs[0])
|
298
|
+
|
299
|
+
# Merge subsequent lists by key
|
300
|
+
for data in data_inputs[1:]:
|
301
|
+
# Process in chunks if data is large
|
302
|
+
if len(data) > chunk_size:
|
303
|
+
for i in range(0, len(data), chunk_size):
|
304
|
+
chunk = data[i : i + chunk_size]
|
305
|
+
await self._merge_dict_chunk(result, chunk, key)
|
306
|
+
if i + chunk_size < len(data):
|
307
|
+
await asyncio.sleep(0.001) # Small delay between chunks
|
308
|
+
else:
|
309
|
+
# For smaller data, process all at once
|
310
|
+
await self._merge_dict_chunk(result, data, key)
|
311
|
+
|
312
|
+
return result
|
313
|
+
else:
|
314
|
+
raise ValueError(
|
315
|
+
"merge_dict requires dict inputs or lists of dicts with a key"
|
316
|
+
)
|
317
|
+
|
318
|
+
async def _merge_dict_chunk(
|
319
|
+
self, result: List[dict], data: List[dict], key: str
|
320
|
+
) -> None:
|
321
|
+
"""Merge a chunk of dictionaries into the result list.
|
322
|
+
|
323
|
+
Args:
|
324
|
+
result: The result list being built (modified in-place)
|
325
|
+
data: Chunk of data to merge in
|
326
|
+
key: Key field for matching
|
327
|
+
"""
|
328
|
+
# Create a lookup by key for efficient matching
|
329
|
+
data_indexed = {item.get(key): item for item in data if isinstance(item, dict)}
|
330
|
+
|
331
|
+
# Update existing items
|
332
|
+
for i, item in enumerate(result):
|
333
|
+
if isinstance(item, dict) and key in item:
|
334
|
+
key_value = item.get(key)
|
335
|
+
if key_value in data_indexed:
|
336
|
+
result[i] = {**item, **data_indexed[key_value]}
|
337
|
+
|
338
|
+
# Add items from current chunk that don't match existing keys
|
339
|
+
result_keys = {
|
340
|
+
item.get(key) for item in result if isinstance(item, dict) and key in item
|
341
|
+
}
|
342
|
+
for item in data:
|
343
|
+
if (
|
344
|
+
isinstance(item, dict)
|
345
|
+
and key in item
|
346
|
+
and item.get(key) not in result_keys
|
347
|
+
):
|
348
|
+
result.append(item)
|
349
|
+
|
350
|
+
|
351
|
+
@register_node()
|
352
|
+
class AsyncSwitch(AsyncNode):
|
353
|
+
"""Asynchronously routes data to different outputs based on conditions.
|
354
|
+
|
355
|
+
Note: We implement run() to fulfill the Node abstract base class requirement,
|
356
|
+
but it's just a pass-through to async_run().
|
357
|
+
|
358
|
+
This node extends the standard Switch node with asynchronous execution capabilities,
|
359
|
+
making it more efficient for:
|
360
|
+
|
361
|
+
1. Processing conditional routing with I/O-bound condition evaluation
|
362
|
+
2. Handling large datasets that need to be routed based on complex criteria
|
363
|
+
3. Integrating with other asynchronous nodes in a workflow
|
364
|
+
|
365
|
+
The basic functionality is the same as the synchronous Switch node but optimized
|
366
|
+
for asynchronous execution.
|
367
|
+
"""
|
368
|
+
|
369
|
+
def get_parameters(self) -> Dict[str, NodeParameter]:
|
370
|
+
"""Define parameters for the AsyncSwitch node."""
|
371
|
+
return {
|
372
|
+
"input_data": NodeParameter(
|
373
|
+
name="input_data",
|
374
|
+
type=Any,
|
375
|
+
required=False, # For testing flexibility - required at execution time
|
376
|
+
description="Input data to route",
|
377
|
+
),
|
378
|
+
"condition_field": NodeParameter(
|
379
|
+
name="condition_field",
|
380
|
+
type=str,
|
381
|
+
required=False,
|
382
|
+
description="Field in input data to evaluate (for dict inputs)",
|
383
|
+
),
|
384
|
+
"operator": NodeParameter(
|
385
|
+
name="operator",
|
386
|
+
type=str,
|
387
|
+
required=False,
|
388
|
+
default="==",
|
389
|
+
description="Comparison operator (==, !=, >, <, >=, <=, in, contains, is_null, is_not_null)",
|
390
|
+
),
|
391
|
+
"value": NodeParameter(
|
392
|
+
name="value",
|
393
|
+
type=Any,
|
394
|
+
required=False,
|
395
|
+
description="Value to compare against for boolean conditions",
|
396
|
+
),
|
397
|
+
"cases": NodeParameter(
|
398
|
+
name="cases",
|
399
|
+
type=list,
|
400
|
+
required=False,
|
401
|
+
description="List of values for multi-case switching",
|
402
|
+
),
|
403
|
+
"case_prefix": NodeParameter(
|
404
|
+
name="case_prefix",
|
405
|
+
type=str,
|
406
|
+
required=False,
|
407
|
+
default="case_",
|
408
|
+
description="Prefix for case output fields",
|
409
|
+
),
|
410
|
+
"default_field": NodeParameter(
|
411
|
+
name="default_field",
|
412
|
+
type=str,
|
413
|
+
required=False,
|
414
|
+
default="default",
|
415
|
+
description="Output field name for default case",
|
416
|
+
),
|
417
|
+
"pass_condition_result": NodeParameter(
|
418
|
+
name="pass_condition_result",
|
419
|
+
type=bool,
|
420
|
+
required=False,
|
421
|
+
default=True,
|
422
|
+
description="Whether to include condition result in outputs",
|
423
|
+
),
|
424
|
+
"break_after_first_match": NodeParameter(
|
425
|
+
name="break_after_first_match",
|
426
|
+
type=bool,
|
427
|
+
required=False,
|
428
|
+
default=True,
|
429
|
+
description="Whether to stop checking cases after the first match",
|
430
|
+
),
|
431
|
+
}
|
432
|
+
|
433
|
+
def get_output_schema(self) -> Dict[str, NodeParameter]:
|
434
|
+
"""Dynamic schema with standard outputs."""
|
435
|
+
return {
|
436
|
+
"true_output": NodeParameter(
|
437
|
+
name="true_output",
|
438
|
+
type=Any,
|
439
|
+
required=False,
|
440
|
+
description="Output when condition is true (boolean mode)",
|
441
|
+
),
|
442
|
+
"false_output": NodeParameter(
|
443
|
+
name="false_output",
|
444
|
+
type=Any,
|
445
|
+
required=False,
|
446
|
+
description="Output when condition is false (boolean mode)",
|
447
|
+
),
|
448
|
+
"default": NodeParameter(
|
449
|
+
name="default",
|
450
|
+
type=Any,
|
451
|
+
required=False,
|
452
|
+
description="Output for default case (multi-case mode)",
|
453
|
+
),
|
454
|
+
"condition_result": NodeParameter(
|
455
|
+
name="condition_result",
|
456
|
+
type=Any,
|
457
|
+
required=False,
|
458
|
+
description="Result of condition evaluation",
|
459
|
+
),
|
460
|
+
# Note: case_X outputs are dynamic and not listed here
|
461
|
+
}
|
462
|
+
|
463
|
+
async def async_run(self, **kwargs) -> Dict[str, Any]:
|
464
|
+
"""Asynchronously execute the switch operation.
|
465
|
+
|
466
|
+
Args:
|
467
|
+
**kwargs: Input parameters including input_data and switch conditions
|
468
|
+
|
469
|
+
Returns:
|
470
|
+
Dict containing the routed data based on condition evaluation
|
471
|
+
|
472
|
+
Raises:
|
473
|
+
ValueError: If required inputs are missing
|
474
|
+
"""
|
475
|
+
# Ensure input_data is provided at execution time
|
476
|
+
if "input_data" not in kwargs:
|
477
|
+
raise ValueError(
|
478
|
+
"Required parameter 'input_data' not provided at execution time"
|
479
|
+
)
|
480
|
+
|
481
|
+
input_data = kwargs["input_data"]
|
482
|
+
condition_field = kwargs.get("condition_field")
|
483
|
+
operator = kwargs.get("operator", "==")
|
484
|
+
value = kwargs.get("value")
|
485
|
+
cases = kwargs.get("cases", [])
|
486
|
+
case_prefix = kwargs.get("case_prefix", "case_")
|
487
|
+
default_field = kwargs.get("default_field", "default")
|
488
|
+
pass_condition_result = kwargs.get("pass_condition_result", True)
|
489
|
+
break_after_first_match = kwargs.get("break_after_first_match", True)
|
490
|
+
|
491
|
+
# Add a small delay to simulate async processing
|
492
|
+
await asyncio.sleep(0.01)
|
493
|
+
|
494
|
+
# Extract the value to check
|
495
|
+
if condition_field:
|
496
|
+
# Handle both single dict and list of dicts
|
497
|
+
if isinstance(input_data, dict):
|
498
|
+
check_value = input_data.get(condition_field)
|
499
|
+
self.logger.debug(
|
500
|
+
f"Extracted value '{check_value}' from dict field '{condition_field}'"
|
501
|
+
)
|
502
|
+
elif (
|
503
|
+
isinstance(input_data, list)
|
504
|
+
and len(input_data) > 0
|
505
|
+
and isinstance(input_data[0], dict)
|
506
|
+
):
|
507
|
+
# For lists of dictionaries, group by the condition field
|
508
|
+
groups = {}
|
509
|
+
for item in input_data:
|
510
|
+
key = item.get(condition_field)
|
511
|
+
if key not in groups:
|
512
|
+
groups[key] = []
|
513
|
+
groups[key].append(item)
|
514
|
+
|
515
|
+
self.logger.debug(
|
516
|
+
f"Grouped data by '{condition_field}': keys={list(groups.keys())}"
|
517
|
+
)
|
518
|
+
return await self._handle_list_grouping(
|
519
|
+
groups, cases, case_prefix, default_field, pass_condition_result
|
520
|
+
)
|
521
|
+
else:
|
522
|
+
check_value = input_data
|
523
|
+
self.logger.debug(
|
524
|
+
f"Field '{condition_field}' specified but input is not a dict or list of dicts"
|
525
|
+
)
|
526
|
+
else:
|
527
|
+
check_value = input_data
|
528
|
+
self.logger.debug("Using input data directly as check value")
|
529
|
+
|
530
|
+
# Debug parameters
|
531
|
+
self.logger.debug(
|
532
|
+
f"AsyncSwitch node parameters: input_data_type={type(input_data)}, "
|
533
|
+
f"condition_field={condition_field}, operator={operator}, "
|
534
|
+
f"value={value}, cases={cases}, case_prefix={case_prefix}"
|
535
|
+
)
|
536
|
+
|
537
|
+
result = {}
|
538
|
+
|
539
|
+
# Multi-case switching
|
540
|
+
if cases:
|
541
|
+
self.logger.debug(
|
542
|
+
f"Performing multi-case switching with {len(cases)} cases"
|
543
|
+
)
|
544
|
+
# Default case always gets the input data
|
545
|
+
result[default_field] = input_data
|
546
|
+
|
547
|
+
# Find which case matches
|
548
|
+
matched_case = None
|
549
|
+
|
550
|
+
# Match cases and populate the matching one
|
551
|
+
for case in cases:
|
552
|
+
if await self._evaluate_condition(check_value, operator, case):
|
553
|
+
# Convert case value to a valid output field name
|
554
|
+
case_str = f"{case_prefix}{self._sanitize_case_name(case)}"
|
555
|
+
result[case_str] = input_data
|
556
|
+
matched_case = case
|
557
|
+
self.logger.debug(f"Case match found: {case}, setting {case_str}")
|
558
|
+
|
559
|
+
if break_after_first_match:
|
560
|
+
break
|
561
|
+
|
562
|
+
# Set condition result
|
563
|
+
if pass_condition_result:
|
564
|
+
result["condition_result"] = matched_case
|
565
|
+
|
566
|
+
# Boolean condition
|
567
|
+
else:
|
568
|
+
self.logger.debug(
|
569
|
+
f"Performing boolean condition check: {check_value} {operator} {value}"
|
570
|
+
)
|
571
|
+
condition_result = await self._evaluate_condition(
|
572
|
+
check_value, operator, value
|
573
|
+
)
|
574
|
+
|
575
|
+
# Route to true_output or false_output based on condition
|
576
|
+
result["true_output"] = input_data if condition_result else None
|
577
|
+
result["false_output"] = None if condition_result else input_data
|
578
|
+
|
579
|
+
if pass_condition_result:
|
580
|
+
result["condition_result"] = condition_result
|
581
|
+
|
582
|
+
self.logger.debug(f"Condition evaluated to {condition_result}")
|
583
|
+
|
584
|
+
# Debug the final result keys
|
585
|
+
self.logger.debug(f"AsyncSwitch node result keys: {list(result.keys())}")
|
586
|
+
return result
|
587
|
+
|
588
|
+
def run(self, **kwargs) -> Dict[str, Any]:
|
589
|
+
"""Synchronous execution method that delegates to the async implementation.
|
590
|
+
|
591
|
+
This method is required by the Node abstract base class but shouldn't
|
592
|
+
be used directly. Use execute_async() instead for async execution.
|
593
|
+
|
594
|
+
Args:
|
595
|
+
**kwargs: Input parameters
|
596
|
+
|
597
|
+
Returns:
|
598
|
+
Dict containing routing results
|
599
|
+
|
600
|
+
Raises:
|
601
|
+
RuntimeError: If called directly (not through execute())
|
602
|
+
"""
|
603
|
+
# This will be properly wrapped by the execute() method
|
604
|
+
# which will call it in a sync context
|
605
|
+
raise RuntimeError(
|
606
|
+
"AsyncSwitch.run() was called directly. Use execute() or execute_async() instead."
|
607
|
+
)
|
608
|
+
|
609
|
+
async def _evaluate_condition(
|
610
|
+
self, check_value: Any, operator: str, compare_value: Any
|
611
|
+
) -> bool:
|
612
|
+
"""Asynchronously evaluate a condition between two values."""
|
613
|
+
try:
|
614
|
+
# Add minimal delay to simulate async processing for complex conditions
|
615
|
+
await asyncio.sleep(0.001)
|
616
|
+
|
617
|
+
if operator == "==":
|
618
|
+
return check_value == compare_value
|
619
|
+
elif operator == "!=":
|
620
|
+
return check_value != compare_value
|
621
|
+
elif operator == ">":
|
622
|
+
return check_value > compare_value
|
623
|
+
elif operator == "<":
|
624
|
+
return check_value < compare_value
|
625
|
+
elif operator == ">=":
|
626
|
+
return check_value >= compare_value
|
627
|
+
elif operator == "<=":
|
628
|
+
return check_value <= compare_value
|
629
|
+
elif operator == "in":
|
630
|
+
return check_value in compare_value
|
631
|
+
elif operator == "contains":
|
632
|
+
return compare_value in check_value
|
633
|
+
elif operator == "is_null":
|
634
|
+
return check_value is None
|
635
|
+
elif operator == "is_not_null":
|
636
|
+
return check_value is not None
|
637
|
+
else:
|
638
|
+
self.logger.error(f"Unknown operator: {operator}")
|
639
|
+
return False
|
640
|
+
except Exception as e:
|
641
|
+
self.logger.error(f"Error evaluating condition: {e}")
|
642
|
+
return False
|
643
|
+
|
644
|
+
def _sanitize_case_name(self, case: Any) -> str:
|
645
|
+
"""Convert a case value to a valid field name."""
|
646
|
+
# Convert to string and replace problematic characters
|
647
|
+
case_str = str(case)
|
648
|
+
case_str = case_str.replace(" ", "_")
|
649
|
+
case_str = case_str.replace("-", "_")
|
650
|
+
case_str = case_str.replace(".", "_")
|
651
|
+
case_str = case_str.replace(":", "_")
|
652
|
+
case_str = case_str.replace("/", "_")
|
653
|
+
return case_str
|
654
|
+
|
655
|
+
async def _handle_list_grouping(
|
656
|
+
self,
|
657
|
+
groups: Dict[Any, List],
|
658
|
+
cases: List[Any],
|
659
|
+
case_prefix: str,
|
660
|
+
default_field: str,
|
661
|
+
pass_condition_result: bool,
|
662
|
+
) -> Dict[str, Any]:
|
663
|
+
"""Asynchronously handle routing when input is a list of dictionaries.
|
664
|
+
|
665
|
+
This method creates outputs for each case with the filtered data.
|
666
|
+
|
667
|
+
Args:
|
668
|
+
groups: Dictionary of data grouped by condition_field values
|
669
|
+
cases: List of case values to match
|
670
|
+
case_prefix: Prefix for case output field names
|
671
|
+
default_field: Field name for default output
|
672
|
+
pass_condition_result: Whether to include condition result
|
673
|
+
|
674
|
+
Returns:
|
675
|
+
Dictionary of outputs with case-specific data
|
676
|
+
"""
|
677
|
+
# Add minimal delay to simulate async processing
|
678
|
+
await asyncio.sleep(0.005)
|
679
|
+
|
680
|
+
result = {
|
681
|
+
default_field: [item for sublist in groups.values() for item in sublist]
|
682
|
+
}
|
683
|
+
|
684
|
+
# Initialize all case outputs with empty lists
|
685
|
+
for case in cases:
|
686
|
+
case_key = f"{case_prefix}{self._sanitize_case_name(case)}"
|
687
|
+
result[case_key] = []
|
688
|
+
|
689
|
+
# Populate matching cases
|
690
|
+
for case in cases:
|
691
|
+
case_key = f"{case_prefix}{self._sanitize_case_name(case)}"
|
692
|
+
if case in groups:
|
693
|
+
result[case_key] = groups[case]
|
694
|
+
self.logger.debug(
|
695
|
+
f"Case match found: {case}, mapped to {case_key} with {len(groups[case])} items"
|
696
|
+
)
|
697
|
+
|
698
|
+
# Set condition results
|
699
|
+
if pass_condition_result:
|
700
|
+
result["condition_result"] = list(set(groups.keys()) & set(cases))
|
701
|
+
|
702
|
+
return result
|