kailash 0.4.2__py3-none-any.whl → 0.5.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.
@@ -0,0 +1,420 @@
1
+ """Resource management utilities for the Kailash SDK.
2
+
3
+ This module provides context managers and utilities for efficient resource
4
+ management across the SDK, ensuring proper cleanup and preventing memory leaks.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import threading
10
+ import weakref
11
+ from collections import defaultdict
12
+ from contextlib import asynccontextmanager, contextmanager
13
+ from datetime import UTC, datetime
14
+ from typing import Any, Callable, Dict, Generic, Optional, Set, TypeVar
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class ResourcePool(Generic[T]):
22
+ """Generic resource pool for connection pooling and resource reuse.
23
+
24
+ This class provides a thread-safe pool for managing expensive resources
25
+ like database connections, HTTP clients, etc.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ factory: Callable[[], T],
31
+ max_size: int = 10,
32
+ timeout: float = 30.0,
33
+ cleanup: Optional[Callable[[T], None]] = None,
34
+ ):
35
+ """Initialize the resource pool.
36
+
37
+ Args:
38
+ factory: Function to create new resources
39
+ max_size: Maximum pool size
40
+ timeout: Timeout for acquiring resources
41
+ cleanup: Optional cleanup function for resources
42
+ """
43
+ self._factory = factory
44
+ self._max_size = max_size
45
+ self._timeout = timeout
46
+ self._cleanup = cleanup
47
+
48
+ self._pool: list[T] = []
49
+ self._in_use: Set[T] = set()
50
+ self._lock = threading.Lock()
51
+ self._semaphore = threading.Semaphore(max_size)
52
+ self._created_count = 0
53
+
54
+ @contextmanager
55
+ def acquire(self):
56
+ """Acquire a resource from the pool.
57
+
58
+ Yields:
59
+ Resource instance
60
+
61
+ Raises:
62
+ TimeoutError: If resource cannot be acquired within timeout
63
+ """
64
+ if not self._semaphore.acquire(timeout=self._timeout):
65
+ raise TimeoutError(f"Failed to acquire resource within {self._timeout}s")
66
+
67
+ resource = None
68
+ try:
69
+ with self._lock:
70
+ # Try to get from pool
71
+ if self._pool:
72
+ resource = self._pool.pop()
73
+ else:
74
+ # Create new resource if under limit
75
+ if self._created_count < self._max_size:
76
+ resource = self._factory()
77
+ self._created_count += 1
78
+ else:
79
+ raise RuntimeError("Pool exhausted")
80
+
81
+ self._in_use.add(resource)
82
+
83
+ yield resource
84
+
85
+ finally:
86
+ if resource is not None:
87
+ with self._lock:
88
+ self._in_use.discard(resource)
89
+ self._pool.append(resource)
90
+ self._semaphore.release()
91
+
92
+ def cleanup_all(self):
93
+ """Clean up all resources in the pool."""
94
+ with self._lock:
95
+ # Clean up pooled resources
96
+ for resource in self._pool:
97
+ if self._cleanup:
98
+ try:
99
+ self._cleanup(resource)
100
+ except Exception as e:
101
+ logger.error(f"Error cleaning up resource: {e}")
102
+
103
+ # Clean up in-use resources (best effort)
104
+ for resource in self._in_use:
105
+ if self._cleanup:
106
+ try:
107
+ self._cleanup(resource)
108
+ except Exception as e:
109
+ logger.error(f"Error cleaning up in-use resource: {e}")
110
+
111
+ self._pool.clear()
112
+ self._in_use.clear()
113
+ self._created_count = 0
114
+
115
+
116
+ class AsyncResourcePool(Generic[T]):
117
+ """Async version of ResourcePool for async resources."""
118
+
119
+ def __init__(
120
+ self,
121
+ factory: Callable[[], T],
122
+ max_size: int = 10,
123
+ timeout: float = 30.0,
124
+ cleanup: Optional[Callable[[T], Any]] = None,
125
+ ):
126
+ """Initialize the async resource pool.
127
+
128
+ Args:
129
+ factory: Async function to create new resources
130
+ max_size: Maximum pool size
131
+ timeout: Timeout for acquiring resources
132
+ cleanup: Optional async cleanup function
133
+ """
134
+ self._factory = factory
135
+ self._max_size = max_size
136
+ self._timeout = timeout
137
+ self._cleanup = cleanup
138
+
139
+ self._pool: list[T] = []
140
+ self._in_use: Set[T] = set()
141
+ self._lock = asyncio.Lock()
142
+ self._semaphore = asyncio.Semaphore(max_size)
143
+ self._created_count = 0
144
+
145
+ @asynccontextmanager
146
+ async def acquire(self):
147
+ """Acquire a resource from the pool asynchronously.
148
+
149
+ Yields:
150
+ Resource instance
151
+
152
+ Raises:
153
+ TimeoutError: If resource cannot be acquired within timeout
154
+ """
155
+ try:
156
+ await asyncio.wait_for(self._semaphore.acquire(), timeout=self._timeout)
157
+ except asyncio.TimeoutError:
158
+ raise TimeoutError(f"Failed to acquire resource within {self._timeout}s")
159
+
160
+ resource = None
161
+ try:
162
+ async with self._lock:
163
+ # Try to get from pool
164
+ if self._pool:
165
+ resource = self._pool.pop()
166
+ else:
167
+ # Create new resource if under limit
168
+ if self._created_count < self._max_size:
169
+ if asyncio.iscoroutinefunction(self._factory):
170
+ resource = await self._factory()
171
+ else:
172
+ resource = self._factory()
173
+ self._created_count += 1
174
+ else:
175
+ raise RuntimeError("Pool exhausted")
176
+
177
+ self._in_use.add(resource)
178
+
179
+ yield resource
180
+
181
+ finally:
182
+ if resource is not None:
183
+ async with self._lock:
184
+ self._in_use.discard(resource)
185
+ self._pool.append(resource)
186
+ self._semaphore.release()
187
+
188
+ async def cleanup_all(self):
189
+ """Clean up all resources in the pool asynchronously."""
190
+ async with self._lock:
191
+ # Clean up pooled resources
192
+ for resource in self._pool:
193
+ if self._cleanup:
194
+ try:
195
+ if asyncio.iscoroutinefunction(self._cleanup):
196
+ await self._cleanup(resource)
197
+ else:
198
+ self._cleanup(resource)
199
+ except Exception as e:
200
+ logger.error(f"Error cleaning up resource: {e}")
201
+
202
+ # Clean up in-use resources (best effort)
203
+ for resource in self._in_use:
204
+ if self._cleanup:
205
+ try:
206
+ if asyncio.iscoroutinefunction(self._cleanup):
207
+ await self._cleanup(resource)
208
+ else:
209
+ self._cleanup(resource)
210
+ except Exception as e:
211
+ logger.error(f"Error cleaning up in-use resource: {e}")
212
+
213
+ self._pool.clear()
214
+ self._in_use.clear()
215
+ self._created_count = 0
216
+
217
+
218
+ class ResourceTracker:
219
+ """Track and manage resources across the SDK to prevent leaks."""
220
+
221
+ def __init__(self):
222
+ self._resources: Dict[str, weakref.WeakSet] = defaultdict(weakref.WeakSet)
223
+ self._metrics: Dict[str, Dict[str, Any]] = defaultdict(dict)
224
+ self._lock = threading.Lock()
225
+
226
+ def register(self, resource_type: str, resource: Any):
227
+ """Register a resource for tracking.
228
+
229
+ Args:
230
+ resource_type: Type/category of resource
231
+ resource: Resource instance to track
232
+ """
233
+ with self._lock:
234
+ self._resources[resource_type].add(resource)
235
+
236
+ # Update metrics
237
+ if resource_type not in self._metrics:
238
+ self._metrics[resource_type] = {
239
+ "created": 0,
240
+ "active": 0,
241
+ "peak": 0,
242
+ "last_created": None,
243
+ }
244
+
245
+ self._metrics[resource_type]["created"] += 1
246
+ self._metrics[resource_type]["active"] = len(self._resources[resource_type])
247
+ self._metrics[resource_type]["peak"] = max(
248
+ self._metrics[resource_type]["peak"],
249
+ self._metrics[resource_type]["active"],
250
+ )
251
+ self._metrics[resource_type]["last_created"] = datetime.now(UTC)
252
+
253
+ def get_metrics(self) -> Dict[str, Dict[str, Any]]:
254
+ """Get current resource metrics.
255
+
256
+ Returns:
257
+ Dictionary of metrics by resource type
258
+ """
259
+ with self._lock:
260
+ # Update active counts
261
+ for resource_type in self._metrics:
262
+ self._metrics[resource_type]["active"] = len(
263
+ self._resources[resource_type]
264
+ )
265
+
266
+ return dict(self._metrics)
267
+
268
+ def get_active_resources(
269
+ self, resource_type: Optional[str] = None
270
+ ) -> Dict[str, int]:
271
+ """Get count of active resources.
272
+
273
+ Args:
274
+ resource_type: Optional filter by type
275
+
276
+ Returns:
277
+ Dictionary of resource type to active count
278
+ """
279
+ with self._lock:
280
+ if resource_type:
281
+ return {resource_type: len(self._resources.get(resource_type, set()))}
282
+ else:
283
+ return {
284
+ rtype: len(resources)
285
+ for rtype, resources in self._resources.items()
286
+ }
287
+
288
+
289
+ # Global resource tracker instance
290
+ _resource_tracker = ResourceTracker()
291
+
292
+
293
+ def get_resource_tracker() -> ResourceTracker:
294
+ """Get the global resource tracker instance."""
295
+ return _resource_tracker
296
+
297
+
298
+ @contextmanager
299
+ def managed_resource(
300
+ resource_type: str, resource: Any, cleanup: Optional[Callable] = None
301
+ ):
302
+ """Context manager for tracking and cleaning up resources.
303
+
304
+ Args:
305
+ resource_type: Type/category of resource
306
+ resource: Resource instance
307
+ cleanup: Optional cleanup function
308
+
309
+ Yields:
310
+ The resource instance
311
+ """
312
+ _resource_tracker.register(resource_type, resource)
313
+
314
+ try:
315
+ yield resource
316
+ finally:
317
+ if cleanup:
318
+ try:
319
+ cleanup(resource)
320
+ except Exception as e:
321
+ logger.error(f"Error cleaning up {resource_type}: {e}")
322
+
323
+
324
+ @asynccontextmanager
325
+ async def async_managed_resource(
326
+ resource_type: str, resource: Any, cleanup: Optional[Callable] = None
327
+ ):
328
+ """Async context manager for tracking and cleaning up resources.
329
+
330
+ Args:
331
+ resource_type: Type/category of resource
332
+ resource: Resource instance
333
+ cleanup: Optional async cleanup function
334
+
335
+ Yields:
336
+ The resource instance
337
+ """
338
+ _resource_tracker.register(resource_type, resource)
339
+
340
+ try:
341
+ yield resource
342
+ finally:
343
+ if cleanup:
344
+ try:
345
+ if asyncio.iscoroutinefunction(cleanup):
346
+ await cleanup(resource)
347
+ else:
348
+ cleanup(resource)
349
+ except Exception as e:
350
+ logger.error(f"Error cleaning up {resource_type}: {e}")
351
+
352
+
353
+ class ConcurrencyLimiter:
354
+ """Limit concurrent operations to prevent resource exhaustion."""
355
+
356
+ def __init__(self, max_concurrent: int = 10):
357
+ """Initialize the concurrency limiter.
358
+
359
+ Args:
360
+ max_concurrent: Maximum concurrent operations
361
+ """
362
+ self._semaphore = threading.Semaphore(max_concurrent)
363
+ self._active = 0
364
+ self._peak = 0
365
+ self._lock = threading.Lock()
366
+
367
+ @contextmanager
368
+ def limit(self):
369
+ """Context manager to limit concurrency."""
370
+ self._semaphore.acquire()
371
+ with self._lock:
372
+ self._active += 1
373
+ self._peak = max(self._peak, self._active)
374
+
375
+ try:
376
+ yield
377
+ finally:
378
+ with self._lock:
379
+ self._active -= 1
380
+ self._semaphore.release()
381
+
382
+ def get_stats(self) -> Dict[str, int]:
383
+ """Get concurrency statistics."""
384
+ with self._lock:
385
+ return {"active": self._active, "peak": self._peak}
386
+
387
+
388
+ class AsyncConcurrencyLimiter:
389
+ """Async version of ConcurrencyLimiter."""
390
+
391
+ def __init__(self, max_concurrent: int = 10):
392
+ """Initialize the async concurrency limiter.
393
+
394
+ Args:
395
+ max_concurrent: Maximum concurrent operations
396
+ """
397
+ self._semaphore = asyncio.Semaphore(max_concurrent)
398
+ self._active = 0
399
+ self._peak = 0
400
+ self._lock = asyncio.Lock()
401
+
402
+ @asynccontextmanager
403
+ async def limit(self):
404
+ """Async context manager to limit concurrency."""
405
+ await self._semaphore.acquire()
406
+ async with self._lock:
407
+ self._active += 1
408
+ self._peak = max(self._peak, self._active)
409
+
410
+ try:
411
+ yield
412
+ finally:
413
+ async with self._lock:
414
+ self._active -= 1
415
+ self._semaphore.release()
416
+
417
+ async def get_stats(self) -> Dict[str, int]:
418
+ """Get concurrency statistics."""
419
+ async with self._lock:
420
+ return {"active": self._active, "peak": self._peak}
@@ -21,7 +21,7 @@ class WorkflowBuilder:
21
21
 
22
22
  def add_node(
23
23
  self,
24
- node_type: str,
24
+ node_type: str | type | Any,
25
25
  node_id: str | None = None,
26
26
  config: dict[str, Any] | None = None,
27
27
  ) -> str:
@@ -29,9 +29,9 @@ class WorkflowBuilder:
29
29
  Add a node to the workflow.
30
30
 
31
31
  Args:
32
- node_type: Node type name
32
+ node_type: Node type name (string), Node class, or Node instance
33
33
  node_id: Unique identifier for this node (auto-generated if not provided)
34
- config: Configuration for the node
34
+ config: Configuration for the node (ignored if node_type is an instance)
35
35
 
36
36
  Returns:
37
37
  Node ID (useful for method chaining)
@@ -48,11 +48,80 @@ class WorkflowBuilder:
48
48
  f"Node ID '{node_id}' already exists in workflow"
49
49
  )
50
50
 
51
- self.nodes[node_id] = {"type": node_type, "config": config or {}}
51
+ # Import Node here to avoid circular imports
52
+ from kailash.nodes.base import Node
53
+
54
+ # Handle different input types
55
+ if isinstance(node_type, str):
56
+ # String node type name
57
+ self.nodes[node_id] = {"type": node_type, "config": config or {}}
58
+ type_name = node_type
59
+ elif isinstance(node_type, type) and issubclass(node_type, Node):
60
+ # Node class
61
+ self.nodes[node_id] = {
62
+ "type": node_type.__name__,
63
+ "config": config or {},
64
+ "class": node_type,
65
+ }
66
+ type_name = node_type.__name__
67
+ elif hasattr(node_type, "__class__") and issubclass(node_type.__class__, Node):
68
+ # Node instance
69
+ self.nodes[node_id] = {
70
+ "instance": node_type,
71
+ "type": node_type.__class__.__name__,
72
+ }
73
+ type_name = node_type.__class__.__name__
74
+ else:
75
+ raise WorkflowValidationError(
76
+ f"Invalid node type: {type(node_type)}. "
77
+ "Expected: str (node type name), Node class, or Node instance"
78
+ )
52
79
 
53
- logger.info(f"Added node '{node_id}' of type '{node_type}'")
80
+ logger.info(f"Added node '{node_id}' of type '{type_name}'")
54
81
  return node_id
55
82
 
83
+ def add_node_instance(self, node_instance: Any, node_id: str | None = None) -> str:
84
+ """
85
+ Add a node instance to the workflow.
86
+
87
+ This is a convenience method for adding pre-configured node instances.
88
+
89
+ Args:
90
+ node_instance: Pre-configured node instance
91
+ node_id: Unique identifier for this node (auto-generated if not provided)
92
+
93
+ Returns:
94
+ Node ID
95
+
96
+ Raises:
97
+ WorkflowValidationError: If node_id is already used or instance is invalid
98
+ """
99
+ return self.add_node(node_instance, node_id)
100
+
101
+ def add_node_type(
102
+ self,
103
+ node_type: str,
104
+ node_id: str | None = None,
105
+ config: dict[str, Any] | None = None,
106
+ ) -> str:
107
+ """
108
+ Add a node by type name to the workflow.
109
+
110
+ This is the original string-based method, provided for clarity and backward compatibility.
111
+
112
+ Args:
113
+ node_type: Node type name as string
114
+ node_id: Unique identifier for this node (auto-generated if not provided)
115
+ config: Configuration for the node
116
+
117
+ Returns:
118
+ Node ID
119
+
120
+ Raises:
121
+ WorkflowValidationError: If node_id is already used
122
+ """
123
+ return self.add_node(node_type, node_id, config)
124
+
56
125
  def add_connection(
57
126
  self, from_node: str, from_output: str, to_node: str, to_input: str
58
127
  ) -> None:
@@ -149,11 +218,25 @@ class WorkflowBuilder:
149
218
  # Add nodes to workflow
150
219
  for node_id, node_info in self.nodes.items():
151
220
  try:
152
- node_type = node_info["type"]
153
- node_config = node_info.get("config", {})
154
-
155
- # Add the node to workflow
156
- workflow._add_node_internal(node_id, node_type, node_config)
221
+ if "instance" in node_info:
222
+ # Node instance was provided
223
+ workflow.add_node(
224
+ node_id=node_id, node_or_type=node_info["instance"]
225
+ )
226
+ elif "class" in node_info:
227
+ # Node class was provided
228
+ node_class = node_info["class"]
229
+ node_config = node_info.get("config", {})
230
+ workflow.add_node(
231
+ node_id=node_id, node_or_type=node_class, **node_config
232
+ )
233
+ else:
234
+ # String node type
235
+ node_type = node_info["type"]
236
+ node_config = node_info.get("config", {})
237
+ workflow.add_node(
238
+ node_id=node_id, node_or_type=node_type, **node_config
239
+ )
157
240
  except Exception as e:
158
241
  raise WorkflowValidationError(
159
242
  f"Failed to add node '{node_id}' to workflow: {e}"
@@ -516,12 +516,11 @@ class CyclicWorkflowExecutor:
516
516
  f"Cycle {cycle_id} iteration now at {cycle_state.iteration} (after update)"
517
517
  )
518
518
 
519
- # Check max iterations (built into monitor.record_iteration)
520
- if cycle_state.iteration >= cycle_config.get(
521
- "max_iterations", float("inf")
522
- ):
519
+ # Check max iterations - loop_count represents actual iterations executed
520
+ max_iterations = cycle_config.get("max_iterations", float("inf"))
521
+ if loop_count >= max_iterations:
523
522
  logger.info(
524
- f"Cycle {cycle_id} reached max iterations: {cycle_state.iteration}"
523
+ f"Cycle {cycle_id} reached max iterations: {loop_count}/{max_iterations}"
525
524
  )
526
525
  should_terminate = True
527
526
 
@@ -643,9 +642,6 @@ class CyclicWorkflowExecutor:
643
642
  if is_cycle_edge and is_cycle_iteration and previous_iteration_results:
644
643
  # For cycle edges after first iteration, use previous iteration results
645
644
  pred_output = previous_iteration_results.get(pred)
646
- logger.debug(
647
- f"Using previous iteration result for {pred} -> {node_id}: {type(pred_output)} keys={list(pred_output.keys()) if isinstance(pred_output, dict) else 'not dict'}"
648
- )
649
645
  elif pred in state.node_outputs:
650
646
  # For non-cycle edges or first iteration, use normal state
651
647
  pred_output = state.node_outputs[pred]
@@ -658,10 +654,6 @@ class CyclicWorkflowExecutor:
658
654
 
659
655
  # Apply mapping
660
656
  mapping = edge_data.get("mapping", {})
661
- if is_cycle_edge and is_cycle_iteration:
662
- logger.debug(
663
- f"Applying cycle mapping: {mapping} from {pred} to {node_id}"
664
- )
665
657
  for src_key, dst_key in mapping.items():
666
658
  # Handle nested output access
667
659
  if "." in src_key:
@@ -677,10 +669,6 @@ class CyclicWorkflowExecutor:
677
669
  inputs[dst_key] = value
678
670
  elif isinstance(pred_output, dict) and src_key in pred_output:
679
671
  inputs[dst_key] = pred_output[src_key]
680
- if is_cycle_edge and is_cycle_iteration:
681
- logger.debug(
682
- f"Mapped {src_key}={pred_output[src_key]} to {dst_key}"
683
- )
684
672
  elif src_key == "output":
685
673
  # Default output mapping
686
674
  inputs[dst_key] = pred_output
@@ -706,10 +694,6 @@ class CyclicWorkflowExecutor:
706
694
  # Recursively filter None values from context to avoid security validation errors
707
695
  context = self._filter_none_values(context)
708
696
 
709
- # Debug inputs before merging
710
- if cycle_state and cycle_state.iteration > 0:
711
- logger.debug(f"Inputs gathered from connections: {inputs}")
712
-
713
697
  # Merge node config with inputs
714
698
  # Order: config < initial_parameters < connection inputs
715
699
  merged_inputs = {**node.config}
@@ -769,11 +753,6 @@ class CyclicWorkflowExecutor:
769
753
  logger.debug(
770
754
  f"Executing node: {node_id} (iteration: {cycle_state.iteration if cycle_state else 'N/A'})"
771
755
  )
772
- logger.debug(f"Node inputs: {list(merged_inputs.keys())}")
773
- if cycle_state:
774
- logger.debug(
775
- f"Input values - value: {merged_inputs.get('value')}, counter: {merged_inputs.get('counter')}"
776
- )
777
756
 
778
757
  try:
779
758
  with collector.collect(node_id=node_id) as metrics_context:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kailash
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for the Kailash container-node architecture
5
5
  Home-page: https://github.com/integrum/kailash-python-sdk
6
6
  Author: Integrum
@@ -120,12 +120,13 @@ Dynamic: requires-python
120
120
  - 🏭 **Session 067 Enhancements**: Business workflow templates, data lineage tracking, automatic credential rotation
121
121
  - 🔄 **Zero-Downtime Operations**: Automatic credential rotation with enterprise notifications and audit trails
122
122
  - 🌉 **Enterprise Middleware (v0.4.0)**: Production-ready middleware architecture with real-time agent-frontend communication, dynamic workflows, and AI chat integration
123
+ - ⚡ **Performance Revolution (v0.5.0)**: 10-100x faster parameter resolution, clear async/sync separation, automatic resource management
123
124
 
124
125
  ## 🏗️ Project Architecture
125
126
 
126
127
  The Kailash project is organized into three distinct layers:
127
128
 
128
- ### Core Architecture (v0.4.0)
129
+ ### Core Architecture (v0.5.0)
129
130
  ```
130
131
  ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
131
132
  │ Frontend │ │ Middleware │ │ Kailash Core │
@@ -150,9 +151,10 @@ kailash_python_sdk/
150
151
  1. **SDK Layer** (`src/kailash/`) - The core framework providing:
151
152
  - Nodes: Reusable computational units (100+ built-in)
152
153
  - Workflows: DAG-based orchestration with cyclic support
153
- - Runtime: Unified execution engine (async + enterprise)
154
- - Middleware: Enterprise communication layer (NEW in v0.4.0)
154
+ - Runtime: Unified execution engine with optimized async/sync separation (v0.5.0)
155
+ - Middleware: Enterprise communication layer (v0.4.0)
155
156
  - Security: RBAC/ABAC access control with audit logging
157
+ - Performance: LRU parameter caching, automatic resource pooling (NEW in v0.5.0)
156
158
 
157
159
  2. **Application Layer** (`apps/`) - Complete applications including:
158
160
  - User Management System (Django++ capabilities)