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.
Files changed (69) hide show
  1. kailash/__init__.py +31 -0
  2. kailash/__main__.py +11 -0
  3. kailash/cli/__init__.py +5 -0
  4. kailash/cli/commands.py +563 -0
  5. kailash/manifest.py +778 -0
  6. kailash/nodes/__init__.py +23 -0
  7. kailash/nodes/ai/__init__.py +26 -0
  8. kailash/nodes/ai/agents.py +417 -0
  9. kailash/nodes/ai/models.py +488 -0
  10. kailash/nodes/api/__init__.py +52 -0
  11. kailash/nodes/api/auth.py +567 -0
  12. kailash/nodes/api/graphql.py +480 -0
  13. kailash/nodes/api/http.py +598 -0
  14. kailash/nodes/api/rate_limiting.py +572 -0
  15. kailash/nodes/api/rest.py +665 -0
  16. kailash/nodes/base.py +1032 -0
  17. kailash/nodes/base_async.py +128 -0
  18. kailash/nodes/code/__init__.py +32 -0
  19. kailash/nodes/code/python.py +1021 -0
  20. kailash/nodes/data/__init__.py +125 -0
  21. kailash/nodes/data/readers.py +496 -0
  22. kailash/nodes/data/sharepoint_graph.py +623 -0
  23. kailash/nodes/data/sql.py +380 -0
  24. kailash/nodes/data/streaming.py +1168 -0
  25. kailash/nodes/data/vector_db.py +964 -0
  26. kailash/nodes/data/writers.py +529 -0
  27. kailash/nodes/logic/__init__.py +6 -0
  28. kailash/nodes/logic/async_operations.py +702 -0
  29. kailash/nodes/logic/operations.py +551 -0
  30. kailash/nodes/transform/__init__.py +5 -0
  31. kailash/nodes/transform/processors.py +379 -0
  32. kailash/runtime/__init__.py +6 -0
  33. kailash/runtime/async_local.py +356 -0
  34. kailash/runtime/docker.py +697 -0
  35. kailash/runtime/local.py +434 -0
  36. kailash/runtime/parallel.py +557 -0
  37. kailash/runtime/runner.py +110 -0
  38. kailash/runtime/testing.py +347 -0
  39. kailash/sdk_exceptions.py +307 -0
  40. kailash/tracking/__init__.py +7 -0
  41. kailash/tracking/manager.py +885 -0
  42. kailash/tracking/metrics_collector.py +342 -0
  43. kailash/tracking/models.py +535 -0
  44. kailash/tracking/storage/__init__.py +0 -0
  45. kailash/tracking/storage/base.py +113 -0
  46. kailash/tracking/storage/database.py +619 -0
  47. kailash/tracking/storage/filesystem.py +543 -0
  48. kailash/utils/__init__.py +0 -0
  49. kailash/utils/export.py +924 -0
  50. kailash/utils/templates.py +680 -0
  51. kailash/visualization/__init__.py +62 -0
  52. kailash/visualization/api.py +732 -0
  53. kailash/visualization/dashboard.py +951 -0
  54. kailash/visualization/performance.py +808 -0
  55. kailash/visualization/reports.py +1471 -0
  56. kailash/workflow/__init__.py +15 -0
  57. kailash/workflow/builder.py +245 -0
  58. kailash/workflow/graph.py +827 -0
  59. kailash/workflow/mermaid_visualizer.py +628 -0
  60. kailash/workflow/mock_registry.py +63 -0
  61. kailash/workflow/runner.py +302 -0
  62. kailash/workflow/state.py +238 -0
  63. kailash/workflow/visualization.py +588 -0
  64. kailash-0.1.0.dist-info/METADATA +710 -0
  65. kailash-0.1.0.dist-info/RECORD +69 -0
  66. kailash-0.1.0.dist-info/WHEEL +5 -0
  67. kailash-0.1.0.dist-info/entry_points.txt +2 -0
  68. kailash-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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