kailash 0.5.0__py3-none-any.whl → 0.6.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 +1 -1
- kailash/client/__init__.py +12 -0
- kailash/client/enhanced_client.py +306 -0
- kailash/core/actors/__init__.py +16 -0
- kailash/core/actors/connection_actor.py +566 -0
- kailash/core/actors/supervisor.py +364 -0
- kailash/edge/__init__.py +16 -0
- kailash/edge/compliance.py +834 -0
- kailash/edge/discovery.py +659 -0
- kailash/edge/location.py +582 -0
- kailash/gateway/__init__.py +33 -0
- kailash/gateway/api.py +289 -0
- kailash/gateway/enhanced_gateway.py +357 -0
- kailash/gateway/resource_resolver.py +217 -0
- kailash/gateway/security.py +227 -0
- kailash/middleware/auth/models.py +2 -2
- kailash/middleware/database/base_models.py +1 -7
- kailash/middleware/gateway/__init__.py +22 -0
- kailash/middleware/gateway/checkpoint_manager.py +398 -0
- kailash/middleware/gateway/deduplicator.py +382 -0
- kailash/middleware/gateway/durable_gateway.py +417 -0
- kailash/middleware/gateway/durable_request.py +498 -0
- kailash/middleware/gateway/event_store.py +459 -0
- kailash/nodes/admin/permission_check.py +817 -33
- kailash/nodes/admin/role_management.py +1242 -108
- kailash/nodes/admin/schema_manager.py +438 -0
- kailash/nodes/admin/user_management.py +1124 -1582
- kailash/nodes/code/__init__.py +8 -1
- kailash/nodes/code/async_python.py +1035 -0
- kailash/nodes/code/python.py +1 -0
- kailash/nodes/data/async_sql.py +9 -3
- kailash/nodes/data/sql.py +20 -11
- kailash/nodes/data/workflow_connection_pool.py +643 -0
- kailash/nodes/rag/__init__.py +1 -4
- kailash/resources/__init__.py +40 -0
- kailash/resources/factory.py +533 -0
- kailash/resources/health.py +319 -0
- kailash/resources/reference.py +288 -0
- kailash/resources/registry.py +392 -0
- kailash/runtime/async_local.py +711 -302
- kailash/testing/__init__.py +34 -0
- kailash/testing/async_test_case.py +353 -0
- kailash/testing/async_utils.py +345 -0
- kailash/testing/fixtures.py +458 -0
- kailash/testing/mock_registry.py +495 -0
- kailash/workflow/__init__.py +8 -0
- kailash/workflow/async_builder.py +621 -0
- kailash/workflow/async_patterns.py +766 -0
- kailash/workflow/cyclic_runner.py +107 -16
- kailash/workflow/graph.py +7 -2
- kailash/workflow/resilience.py +11 -1
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/METADATA +7 -4
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/RECORD +57 -22
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/WHEEL +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
"""Kailash Async Testing Framework.
|
2
|
+
|
3
|
+
Comprehensive testing utilities for async workflows.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from .async_test_case import AsyncWorkflowTestCase, WorkflowTestResult
|
7
|
+
from .async_utils import AsyncAssertions, AsyncTestUtils
|
8
|
+
from .fixtures import (
|
9
|
+
AsyncWorkflowFixtures,
|
10
|
+
DatabaseFixture,
|
11
|
+
MockCache,
|
12
|
+
MockHttpClient,
|
13
|
+
TestHttpServer,
|
14
|
+
)
|
15
|
+
from .mock_registry import CallRecord, MockResource, MockResourceRegistry
|
16
|
+
|
17
|
+
__all__ = [
|
18
|
+
# Core test case
|
19
|
+
"AsyncWorkflowTestCase",
|
20
|
+
"WorkflowTestResult",
|
21
|
+
# Mock system
|
22
|
+
"MockResourceRegistry",
|
23
|
+
"CallRecord",
|
24
|
+
"MockResource",
|
25
|
+
# Async utilities
|
26
|
+
"AsyncTestUtils",
|
27
|
+
"AsyncAssertions",
|
28
|
+
# Fixtures
|
29
|
+
"AsyncWorkflowFixtures",
|
30
|
+
"MockHttpClient",
|
31
|
+
"MockCache",
|
32
|
+
"DatabaseFixture",
|
33
|
+
"TestHttpServer",
|
34
|
+
]
|
@@ -0,0 +1,353 @@
|
|
1
|
+
"""Base class for async workflow testing."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import functools
|
5
|
+
import logging
|
6
|
+
from contextlib import asynccontextmanager
|
7
|
+
from dataclasses import dataclass, field
|
8
|
+
from datetime import datetime, timezone
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
|
10
|
+
|
11
|
+
from ..resources.registry import ResourceFactory, ResourceRegistry
|
12
|
+
from ..runtime.async_local import AsyncLocalRuntime, ExecutionContext
|
13
|
+
from ..workflow.graph import Workflow
|
14
|
+
from .mock_registry import MockResourceRegistry
|
15
|
+
|
16
|
+
T = TypeVar("T")
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class WorkflowTestResult:
|
22
|
+
"""Result from test workflow execution."""
|
23
|
+
|
24
|
+
status: str
|
25
|
+
outputs: Dict[str, Any]
|
26
|
+
errors: List[tuple[str, Exception]] = field(default_factory=list)
|
27
|
+
error: Optional[str] = None
|
28
|
+
execution_time: float = 0.0
|
29
|
+
node_timings: Dict[str, float] = field(default_factory=dict)
|
30
|
+
logs: List[str] = field(default_factory=list)
|
31
|
+
stdout: str = ""
|
32
|
+
stderr: str = ""
|
33
|
+
|
34
|
+
def get_output(self, node_id: str, key: Optional[str] = None) -> Any:
|
35
|
+
"""Get output from specific node."""
|
36
|
+
output = self.outputs.get(node_id)
|
37
|
+
|
38
|
+
if key and isinstance(output, dict):
|
39
|
+
# Handle nested keys
|
40
|
+
keys = key.split(".")
|
41
|
+
for k in keys:
|
42
|
+
if isinstance(output, dict):
|
43
|
+
output = output.get(k)
|
44
|
+
else:
|
45
|
+
return None
|
46
|
+
|
47
|
+
return output
|
48
|
+
|
49
|
+
def get_logs(self) -> str:
|
50
|
+
"""Get formatted logs."""
|
51
|
+
return "\n".join(self.logs)
|
52
|
+
|
53
|
+
def print_summary(self):
|
54
|
+
"""Print execution summary."""
|
55
|
+
print(f"Status: {self.status}")
|
56
|
+
print(f"Execution time: {self.execution_time:.2f}s")
|
57
|
+
|
58
|
+
if self.errors:
|
59
|
+
print(f"Errors: {len(self.errors)}")
|
60
|
+
for node_id, error in self.errors:
|
61
|
+
print(f" - {node_id}: {error}")
|
62
|
+
|
63
|
+
print(f"Nodes executed: {len(self.outputs)}")
|
64
|
+
for node_id, timing in self.node_timings.items():
|
65
|
+
print(f" - {node_id}: {timing:.3f}s")
|
66
|
+
|
67
|
+
|
68
|
+
class AsyncWorkflowTestCase:
|
69
|
+
"""Base class for async workflow testing."""
|
70
|
+
|
71
|
+
def __init__(self, test_name: str = None):
|
72
|
+
self.test_name = test_name or self.__class__.__name__
|
73
|
+
self.resource_registry = ResourceRegistry()
|
74
|
+
self.mock_registry = MockResourceRegistry()
|
75
|
+
self._cleanup_tasks: List[Callable] = []
|
76
|
+
self._test_resources: Dict[str, Any] = {}
|
77
|
+
self._assertions_made = 0
|
78
|
+
self._start_time: Optional[datetime] = None
|
79
|
+
|
80
|
+
async def setUp(self):
|
81
|
+
"""Override to set up test resources."""
|
82
|
+
self._start_time = datetime.now(timezone.utc)
|
83
|
+
logger.info(f"Setting up test: {self.test_name}")
|
84
|
+
|
85
|
+
async def tearDown(self):
|
86
|
+
"""Override for custom cleanup."""
|
87
|
+
logger.info(f"Tearing down test: {self.test_name}")
|
88
|
+
|
89
|
+
async def __aenter__(self):
|
90
|
+
"""Async context manager entry."""
|
91
|
+
await self.setUp()
|
92
|
+
return self
|
93
|
+
|
94
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
95
|
+
"""Async context manager exit with cleanup."""
|
96
|
+
# Always run tearDown
|
97
|
+
try:
|
98
|
+
await self.tearDown()
|
99
|
+
except Exception as e:
|
100
|
+
logger.error(f"Error in tearDown: {e}")
|
101
|
+
|
102
|
+
# Run all cleanup tasks in reverse order
|
103
|
+
for task in reversed(self._cleanup_tasks):
|
104
|
+
try:
|
105
|
+
if asyncio.iscoroutinefunction(task):
|
106
|
+
await task()
|
107
|
+
else:
|
108
|
+
task()
|
109
|
+
except Exception as e:
|
110
|
+
logger.error(f"Cleanup failed: {e}")
|
111
|
+
|
112
|
+
# Clean up registries
|
113
|
+
try:
|
114
|
+
await self.resource_registry.cleanup()
|
115
|
+
except Exception as e:
|
116
|
+
logger.error(f"Resource registry cleanup failed: {e}")
|
117
|
+
|
118
|
+
# Log test summary
|
119
|
+
if self._start_time:
|
120
|
+
duration = (datetime.now(timezone.utc) - self._start_time).total_seconds()
|
121
|
+
logger.info(
|
122
|
+
f"Test {self.test_name} completed in {duration:.2f}s "
|
123
|
+
f"with {self._assertions_made} assertions"
|
124
|
+
)
|
125
|
+
|
126
|
+
def add_cleanup(self, cleanup_func: Callable):
|
127
|
+
"""Register a cleanup function/coroutine."""
|
128
|
+
self._cleanup_tasks.append(cleanup_func)
|
129
|
+
|
130
|
+
async def create_test_resource(
|
131
|
+
self,
|
132
|
+
name: str,
|
133
|
+
factory: Union[ResourceFactory, Callable],
|
134
|
+
mock: bool = False,
|
135
|
+
health_check: Callable = None,
|
136
|
+
cleanup_handler: Callable = None,
|
137
|
+
) -> Any:
|
138
|
+
"""Create a test resource with automatic cleanup."""
|
139
|
+
if mock:
|
140
|
+
# Create mock resource
|
141
|
+
resource = await self.mock_registry.create_mock(name, factory)
|
142
|
+
else:
|
143
|
+
# Handle callable factories
|
144
|
+
if callable(factory) and not hasattr(factory, "create"):
|
145
|
+
# Wrap in a proper factory
|
146
|
+
class CallableFactory:
|
147
|
+
def __init__(self, func):
|
148
|
+
self.func = func
|
149
|
+
|
150
|
+
async def create(self):
|
151
|
+
if asyncio.iscoroutinefunction(self.func):
|
152
|
+
return await self.func()
|
153
|
+
return self.func()
|
154
|
+
|
155
|
+
factory = CallableFactory(factory)
|
156
|
+
|
157
|
+
# Register real resource
|
158
|
+
self.resource_registry.register_factory(
|
159
|
+
name,
|
160
|
+
factory,
|
161
|
+
health_check=health_check,
|
162
|
+
cleanup_handler=cleanup_handler,
|
163
|
+
)
|
164
|
+
resource = await self.resource_registry.get_resource(name)
|
165
|
+
|
166
|
+
# Store for later access
|
167
|
+
self._test_resources[name] = resource
|
168
|
+
|
169
|
+
# Register cleanup if not already handled
|
170
|
+
if not cleanup_handler:
|
171
|
+
|
172
|
+
async def default_cleanup():
|
173
|
+
if hasattr(resource, "close"):
|
174
|
+
if asyncio.iscoroutinefunction(resource.close):
|
175
|
+
await resource.close()
|
176
|
+
else:
|
177
|
+
resource.close()
|
178
|
+
elif hasattr(resource, "cleanup"):
|
179
|
+
if asyncio.iscoroutinefunction(resource.cleanup):
|
180
|
+
await resource.cleanup()
|
181
|
+
else:
|
182
|
+
resource.cleanup()
|
183
|
+
|
184
|
+
self.add_cleanup(default_cleanup)
|
185
|
+
|
186
|
+
return resource
|
187
|
+
|
188
|
+
async def execute_workflow(
|
189
|
+
self,
|
190
|
+
workflow: Workflow,
|
191
|
+
inputs: Dict[str, Any],
|
192
|
+
mock_resources: Dict[str, Any] = None,
|
193
|
+
timeout: float = 30.0,
|
194
|
+
capture_logs: bool = True,
|
195
|
+
) -> WorkflowTestResult:
|
196
|
+
"""Execute workflow with test environment."""
|
197
|
+
# Register mock resources
|
198
|
+
if mock_resources:
|
199
|
+
for name, mock in mock_resources.items():
|
200
|
+
self.mock_registry.register_mock(name, mock)
|
201
|
+
|
202
|
+
# Create a test resource registry that checks mocks first
|
203
|
+
class TestResourceRegistry:
|
204
|
+
def __init__(self, real_registry, mock_registry):
|
205
|
+
self.real_registry = real_registry
|
206
|
+
self.mock_registry = mock_registry
|
207
|
+
# Copy all attributes from real registry to ensure compatibility
|
208
|
+
if real_registry:
|
209
|
+
for attr in dir(real_registry):
|
210
|
+
if not attr.startswith("_") and not hasattr(self, attr):
|
211
|
+
try:
|
212
|
+
setattr(self, attr, getattr(real_registry, attr))
|
213
|
+
except AttributeError:
|
214
|
+
pass
|
215
|
+
|
216
|
+
async def get_resource(self, name: str):
|
217
|
+
# Check mock registry first
|
218
|
+
mock = self.mock_registry.get_mock(name)
|
219
|
+
if mock is not None:
|
220
|
+
return mock
|
221
|
+
# Fall back to real resources
|
222
|
+
if self.real_registry:
|
223
|
+
return await self.real_registry.get_resource(name)
|
224
|
+
raise RuntimeError(f"No resource '{name}' found")
|
225
|
+
|
226
|
+
def register_factory(self, name: str, factory):
|
227
|
+
"""Delegate factory registration to real registry."""
|
228
|
+
if self.real_registry:
|
229
|
+
return self.real_registry.register_factory(name, factory)
|
230
|
+
|
231
|
+
def list_resources(self):
|
232
|
+
"""List all available resources."""
|
233
|
+
resources = []
|
234
|
+
if self.real_registry:
|
235
|
+
resources.extend(self.real_registry.list_resources())
|
236
|
+
resources.extend(self.mock_registry.list_mocks())
|
237
|
+
return resources
|
238
|
+
|
239
|
+
def __getattr__(self, name):
|
240
|
+
# Delegate other methods to real registry
|
241
|
+
if self.real_registry and hasattr(self.real_registry, name):
|
242
|
+
return getattr(self.real_registry, name)
|
243
|
+
raise AttributeError(
|
244
|
+
f"'{type(self).__name__}' has no attribute '{name}'"
|
245
|
+
)
|
246
|
+
|
247
|
+
# Create test runtime with test resource registry
|
248
|
+
test_registry = TestResourceRegistry(self.resource_registry, self.mock_registry)
|
249
|
+
runtime = AsyncLocalRuntime(resource_registry=test_registry)
|
250
|
+
|
251
|
+
# Execute with timeout
|
252
|
+
start_time = asyncio.get_event_loop().time()
|
253
|
+
logs = []
|
254
|
+
|
255
|
+
try:
|
256
|
+
# Execute workflow
|
257
|
+
result = await asyncio.wait_for(
|
258
|
+
runtime.execute_workflow_async(workflow, inputs), timeout=timeout
|
259
|
+
)
|
260
|
+
|
261
|
+
# Convert to test result
|
262
|
+
return WorkflowTestResult(
|
263
|
+
status="success" if not result.get("errors") else "failed",
|
264
|
+
outputs=result.get("results", {}),
|
265
|
+
errors=[
|
266
|
+
(node, error) for node, error in result.get("errors", {}).items()
|
267
|
+
],
|
268
|
+
error=(
|
269
|
+
list(result.get("errors", {}).values())[0]
|
270
|
+
if result.get("errors")
|
271
|
+
else None
|
272
|
+
),
|
273
|
+
execution_time=result.get(
|
274
|
+
"total_duration", asyncio.get_event_loop().time() - start_time
|
275
|
+
),
|
276
|
+
node_timings=result.get("execution_times", {}),
|
277
|
+
logs=logs,
|
278
|
+
)
|
279
|
+
|
280
|
+
except asyncio.TimeoutError:
|
281
|
+
raise AssertionError(f"Workflow execution timed out after {timeout}s")
|
282
|
+
except Exception as e:
|
283
|
+
# Create error result
|
284
|
+
return WorkflowTestResult(
|
285
|
+
status="failed",
|
286
|
+
outputs={},
|
287
|
+
error=str(e),
|
288
|
+
errors=[("execution", e)],
|
289
|
+
execution_time=asyncio.get_event_loop().time() - start_time,
|
290
|
+
logs=logs,
|
291
|
+
)
|
292
|
+
|
293
|
+
def get_resource(self, name: str) -> Any:
|
294
|
+
"""Get a test resource by name."""
|
295
|
+
if name in self._test_resources:
|
296
|
+
return self._test_resources[name]
|
297
|
+
raise KeyError(f"Test resource '{name}' not found")
|
298
|
+
|
299
|
+
# Assertion helpers
|
300
|
+
def assert_workflow_success(self, result: WorkflowTestResult):
|
301
|
+
"""Assert workflow completed successfully."""
|
302
|
+
self._assertions_made += 1
|
303
|
+
assert result.status == "success", (
|
304
|
+
f"Workflow failed: {result.error}\n"
|
305
|
+
f"Errors: {result.errors}\n"
|
306
|
+
f"Logs: {result.get_logs()}"
|
307
|
+
)
|
308
|
+
|
309
|
+
def assert_workflow_failed(self, result: WorkflowTestResult):
|
310
|
+
"""Assert workflow failed."""
|
311
|
+
self._assertions_made += 1
|
312
|
+
assert result.status == "failed", "Workflow did not fail as expected"
|
313
|
+
|
314
|
+
def assert_node_output(
|
315
|
+
self, result: WorkflowTestResult, node_id: str, expected: Any, key: str = None
|
316
|
+
):
|
317
|
+
"""Assert node output matches expected."""
|
318
|
+
self._assertions_made += 1
|
319
|
+
actual = result.get_output(node_id, key)
|
320
|
+
assert actual == expected, (
|
321
|
+
f"Node {node_id} output mismatch\n"
|
322
|
+
f"Expected: {expected}\n"
|
323
|
+
f"Actual: {actual}"
|
324
|
+
)
|
325
|
+
|
326
|
+
def assert_resource_called(
|
327
|
+
self,
|
328
|
+
resource_name: str,
|
329
|
+
method_name: str,
|
330
|
+
times: int = None,
|
331
|
+
with_args: tuple = None,
|
332
|
+
with_kwargs: dict = None,
|
333
|
+
):
|
334
|
+
"""Assert a resource method was called."""
|
335
|
+
self._assertions_made += 1
|
336
|
+
self.mock_registry.assert_called(
|
337
|
+
resource_name,
|
338
|
+
method_name,
|
339
|
+
times=times,
|
340
|
+
with_args=with_args,
|
341
|
+
with_kwargs=with_kwargs,
|
342
|
+
)
|
343
|
+
|
344
|
+
@asynccontextmanager
|
345
|
+
async def assert_time_limit(self, seconds: float):
|
346
|
+
"""Context manager to assert code completes within time limit."""
|
347
|
+
start = asyncio.get_event_loop().time()
|
348
|
+
yield
|
349
|
+
elapsed = asyncio.get_event_loop().time() - start
|
350
|
+
self._assertions_made += 1
|
351
|
+
assert elapsed < seconds, (
|
352
|
+
f"Operation took {elapsed:.2f}s, " f"exceeding limit of {seconds}s"
|
353
|
+
)
|