kailash 0.2.1__py3-none-any.whl → 0.3.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 (37) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/api/custom_nodes_secure.py +2 -2
  3. kailash/api/studio_secure.py +1 -1
  4. kailash/mcp/client_new.py +1 -1
  5. kailash/nodes/ai/a2a.py +1 -1
  6. kailash/nodes/api/__init__.py +26 -0
  7. kailash/nodes/api/monitoring.py +463 -0
  8. kailash/nodes/api/security.py +822 -0
  9. kailash/nodes/base.py +3 -3
  10. kailash/nodes/code/python.py +6 -0
  11. kailash/nodes/data/__init__.py +9 -0
  12. kailash/nodes/data/directory.py +278 -0
  13. kailash/nodes/data/event_generation.py +297 -0
  14. kailash/nodes/data/file_discovery.py +601 -0
  15. kailash/nodes/data/sql.py +2 -2
  16. kailash/nodes/transform/processors.py +32 -1
  17. kailash/runtime/async_local.py +1 -1
  18. kailash/runtime/docker.py +4 -4
  19. kailash/runtime/local.py +41 -4
  20. kailash/runtime/parallel.py +2 -2
  21. kailash/runtime/parallel_cyclic.py +2 -2
  22. kailash/runtime/testing.py +2 -2
  23. kailash/utils/templates.py +6 -6
  24. kailash/visualization/performance.py +16 -3
  25. kailash/visualization/reports.py +5 -1
  26. kailash/workflow/convergence.py +1 -1
  27. kailash/workflow/cycle_analyzer.py +8 -1
  28. kailash/workflow/cyclic_runner.py +1 -1
  29. kailash/workflow/graph.py +33 -6
  30. kailash/workflow/visualization.py +10 -2
  31. kailash-0.3.0.dist-info/METADATA +428 -0
  32. {kailash-0.2.1.dist-info → kailash-0.3.0.dist-info}/RECORD +36 -31
  33. kailash-0.2.1.dist-info/METADATA +0 -1617
  34. {kailash-0.2.1.dist-info → kailash-0.3.0.dist-info}/WHEEL +0 -0
  35. {kailash-0.2.1.dist-info → kailash-0.3.0.dist-info}/entry_points.txt +0 -0
  36. {kailash-0.2.1.dist-info → kailash-0.3.0.dist-info}/licenses/LICENSE +0 -0
  37. {kailash-0.2.1.dist-info → kailash-0.3.0.dist-info}/top_level.txt +0 -0
kailash/nodes/base.py CHANGED
@@ -407,9 +407,9 @@ class Node(ABC):
407
407
  for param_name, param_def in params.items():
408
408
  if param_name not in self.config:
409
409
  if param_def.required and param_def.default is None:
410
- raise NodeConfigurationError(
411
- f"Required parameter '{param_name}' not provided in configuration"
412
- )
410
+ # During node construction, we may not have all parameters yet
411
+ # Skip validation for required parameters - they will be validated at execution time
412
+ continue
413
413
  elif param_def.default is not None:
414
414
  self.config[param_name] = param_def.default
415
415
 
@@ -93,6 +93,12 @@ ALLOWED_MODULES = {
93
93
  "matplotlib",
94
94
  "seaborn",
95
95
  "plotly",
96
+ # File processing modules
97
+ "csv", # For CSV file processing
98
+ "mimetypes", # For MIME type detection
99
+ "pathlib", # For modern path operations
100
+ "glob", # For file pattern matching
101
+ "xml", # For XML processing
96
102
  }
97
103
 
98
104
 
@@ -80,6 +80,9 @@ Example Workflows:
80
80
  workflow.connect('process', 'publish')
81
81
  """
82
82
 
83
+ from kailash.nodes.data.directory import DirectoryReaderNode
84
+ from kailash.nodes.data.event_generation import EventGeneratorNode
85
+ from kailash.nodes.data.file_discovery import FileDiscoveryNode
83
86
  from kailash.nodes.data.readers import CSVReaderNode, JSONReaderNode, TextReaderNode
84
87
  from kailash.nodes.data.retrieval import RelevanceScorerNode
85
88
  from kailash.nodes.data.sharepoint_graph import (
@@ -102,6 +105,12 @@ from kailash.nodes.data.vector_db import (
102
105
  from kailash.nodes.data.writers import CSVWriterNode, JSONWriterNode, TextWriterNode
103
106
 
104
107
  __all__ = [
108
+ # Directory
109
+ "DirectoryReaderNode",
110
+ # Event Generation
111
+ "EventGeneratorNode",
112
+ # File Discovery
113
+ "FileDiscoveryNode",
105
114
  # Readers
106
115
  "CSVReaderNode",
107
116
  "JSONReaderNode",
@@ -0,0 +1,278 @@
1
+ """Directory processing nodes for file discovery and batch operations."""
2
+
3
+ import mimetypes
4
+ import os
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from kailash.nodes.base import Node, NodeParameter, register_node
9
+ from kailash.security import validate_file_path
10
+
11
+
12
+ @register_node()
13
+ class DirectoryReaderNode(Node):
14
+ """
15
+ Discovers and catalogs files in a directory with metadata extraction.
16
+
17
+ This node provides comprehensive directory scanning capabilities, handling
18
+ file discovery, metadata extraction, and filtering. It's designed for
19
+ batch file processing workflows and dynamic data source discovery.
20
+
21
+ Design Philosophy:
22
+ The DirectoryReaderNode embodies the principle of "dynamic data discovery."
23
+ Instead of hardcoding file paths, workflows can dynamically discover
24
+ available data sources at runtime. This makes workflows more flexible
25
+ and adaptable to changing data environments.
26
+
27
+ Features:
28
+ - Recursive directory scanning
29
+ - File type detection and filtering
30
+ - Metadata extraction (size, timestamps, MIME types)
31
+ - Pattern-based filtering
32
+ - Security-validated path operations
33
+
34
+ Use Cases:
35
+ - Batch file processing workflows
36
+ - Dynamic data pipeline creation
37
+ - File monitoring and cataloging
38
+ - Multi-format document processing
39
+ - Data lake exploration
40
+
41
+ Output Format:
42
+ Returns a structured catalog of discovered files with:
43
+ - File paths and names
44
+ - File types and MIME types
45
+ - File sizes and timestamps
46
+ - Directory structure information
47
+ """
48
+
49
+ def get_parameters(self) -> Dict[str, NodeParameter]:
50
+ """Define input parameters for directory scanning."""
51
+ return {
52
+ "directory_path": NodeParameter(
53
+ name="directory_path",
54
+ type=str,
55
+ required=True,
56
+ description="Path to the directory to scan",
57
+ ),
58
+ "recursive": NodeParameter(
59
+ name="recursive",
60
+ type=bool,
61
+ required=False,
62
+ default=False,
63
+ description="Whether to scan subdirectories recursively",
64
+ ),
65
+ "file_patterns": NodeParameter(
66
+ name="file_patterns",
67
+ type=list,
68
+ required=False,
69
+ default=[],
70
+ description="List of file patterns to include (e.g., ['*.csv', '*.json'])",
71
+ ),
72
+ "exclude_patterns": NodeParameter(
73
+ name="exclude_patterns",
74
+ type=list,
75
+ required=False,
76
+ default=[],
77
+ description="List of file patterns to exclude",
78
+ ),
79
+ "include_hidden": NodeParameter(
80
+ name="include_hidden",
81
+ type=bool,
82
+ required=False,
83
+ default=False,
84
+ description="Whether to include hidden files (starting with .)",
85
+ ),
86
+ }
87
+
88
+ def run(self, **kwargs) -> Dict[str, Any]:
89
+ """Execute directory scanning operation.
90
+
91
+ Returns:
92
+ Dictionary containing:
93
+ - discovered_files: List of file information dictionaries
94
+ - files_by_type: Files grouped by type
95
+ - directory_stats: Summary statistics
96
+ """
97
+ directory_path = kwargs.get("directory_path")
98
+ recursive = kwargs.get("recursive", False)
99
+ file_patterns = kwargs.get("file_patterns", [])
100
+ exclude_patterns = kwargs.get("exclude_patterns", [])
101
+ include_hidden = kwargs.get("include_hidden", False)
102
+
103
+ # Validate directory path for security
104
+ validated_path = validate_file_path(directory_path, operation="directory scan")
105
+
106
+ if not os.path.isdir(validated_path):
107
+ raise FileNotFoundError(f"Directory not found: {directory_path}")
108
+
109
+ discovered_files = []
110
+
111
+ try:
112
+ if recursive:
113
+ # Recursive scan
114
+ for root, dirs, files in os.walk(validated_path):
115
+ for filename in files:
116
+ file_path = os.path.join(root, filename)
117
+ file_info = self._extract_file_info(
118
+ file_path,
119
+ filename,
120
+ include_hidden,
121
+ file_patterns,
122
+ exclude_patterns,
123
+ )
124
+ if file_info:
125
+ discovered_files.append(file_info)
126
+ else:
127
+ # Single directory scan
128
+ for filename in os.listdir(validated_path):
129
+ file_path = os.path.join(validated_path, filename)
130
+
131
+ # Skip directories in non-recursive mode
132
+ if os.path.isdir(file_path):
133
+ continue
134
+
135
+ file_info = self._extract_file_info(
136
+ file_path,
137
+ filename,
138
+ include_hidden,
139
+ file_patterns,
140
+ exclude_patterns,
141
+ )
142
+ if file_info:
143
+ discovered_files.append(file_info)
144
+
145
+ except PermissionError as e:
146
+ raise PermissionError(f"Permission denied accessing directory: {e}")
147
+ except Exception as e:
148
+ raise RuntimeError(f"Error scanning directory: {e}")
149
+
150
+ # Group files by type
151
+ files_by_type = {}
152
+ for file_info in discovered_files:
153
+ file_type = file_info["file_type"]
154
+ if file_type not in files_by_type:
155
+ files_by_type[file_type] = []
156
+ files_by_type[file_type].append(file_info)
157
+
158
+ # Generate directory statistics
159
+ directory_stats = {
160
+ "total_files": len(discovered_files),
161
+ "file_types": list(files_by_type.keys()),
162
+ "files_by_type_count": {
163
+ file_type: len(files) for file_type, files in files_by_type.items()
164
+ },
165
+ "total_size": sum(f["file_size"] for f in discovered_files),
166
+ "scan_time": datetime.now().isoformat(),
167
+ "directory_path": directory_path,
168
+ "recursive": recursive,
169
+ }
170
+
171
+ return {
172
+ "discovered_files": discovered_files,
173
+ "files_by_type": files_by_type,
174
+ "directory_stats": directory_stats,
175
+ }
176
+
177
+ def _extract_file_info(
178
+ self,
179
+ file_path: str,
180
+ filename: str,
181
+ include_hidden: bool,
182
+ file_patterns: List[str],
183
+ exclude_patterns: List[str],
184
+ ) -> Optional[Dict[str, Any]]:
185
+ """Extract metadata from a single file.
186
+
187
+ Args:
188
+ file_path: Full path to the file
189
+ filename: Name of the file
190
+ include_hidden: Whether to include hidden files
191
+ file_patterns: Patterns to include
192
+ exclude_patterns: Patterns to exclude
193
+
194
+ Returns:
195
+ File information dictionary or None if file should be excluded
196
+ """
197
+ # Skip hidden files if not included
198
+ if not include_hidden and filename.startswith("."):
199
+ return None
200
+
201
+ # Check exclude patterns
202
+ for pattern in exclude_patterns:
203
+ if self._matches_pattern(filename, pattern):
204
+ return None
205
+
206
+ # Check include patterns (if specified)
207
+ if file_patterns:
208
+ included = any(
209
+ self._matches_pattern(filename, pattern) for pattern in file_patterns
210
+ )
211
+ if not included:
212
+ return None
213
+
214
+ try:
215
+ # Get file statistics
216
+ file_stat = os.stat(file_path)
217
+ file_ext = os.path.splitext(filename)[1].lower()
218
+
219
+ # Map extensions to types
220
+ ext_to_type = {
221
+ ".csv": "csv",
222
+ ".json": "json",
223
+ ".txt": "txt",
224
+ ".xml": "xml",
225
+ ".md": "markdown",
226
+ ".py": "python",
227
+ ".js": "javascript",
228
+ ".html": "html",
229
+ ".css": "css",
230
+ ".pdf": "pdf",
231
+ ".doc": "word",
232
+ ".docx": "word",
233
+ ".xls": "excel",
234
+ ".xlsx": "excel",
235
+ ".png": "image",
236
+ ".jpg": "image",
237
+ ".jpeg": "image",
238
+ ".gif": "image",
239
+ ".svg": "image",
240
+ }
241
+
242
+ file_type = ext_to_type.get(file_ext, "unknown")
243
+
244
+ # Get MIME type
245
+ mime_type, _ = mimetypes.guess_type(file_path)
246
+ if not mime_type:
247
+ mime_type = "application/octet-stream"
248
+
249
+ return {
250
+ "file_path": file_path,
251
+ "file_name": filename,
252
+ "file_type": file_type,
253
+ "file_extension": file_ext,
254
+ "file_size": file_stat.st_size,
255
+ "mime_type": mime_type,
256
+ "created_time": datetime.fromtimestamp(file_stat.st_ctime).isoformat(),
257
+ "modified_time": datetime.fromtimestamp(file_stat.st_mtime).isoformat(),
258
+ "discovered_at": datetime.now().isoformat(),
259
+ }
260
+
261
+ except (OSError, PermissionError) as e:
262
+ # Log error but continue with other files
263
+ self.logger.warning(f"Could not process file {file_path}: {e}")
264
+ return None
265
+
266
+ def _matches_pattern(self, filename: str, pattern: str) -> bool:
267
+ """Check if filename matches a glob-style pattern.
268
+
269
+ Args:
270
+ filename: Name of the file to check
271
+ pattern: Glob pattern (e.g., '*.csv', 'data*', 'file?.txt')
272
+
273
+ Returns:
274
+ True if filename matches pattern
275
+ """
276
+ import fnmatch
277
+
278
+ return fnmatch.fnmatch(filename, pattern)
@@ -0,0 +1,297 @@
1
+ """Event generation nodes for event-driven architectures."""
2
+
3
+ import random
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict
7
+
8
+ from kailash.nodes.base import Node, NodeParameter, register_node
9
+
10
+
11
+ @register_node()
12
+ class EventGeneratorNode(Node):
13
+ """
14
+ Generates events for event sourcing and event-driven architecture patterns.
15
+
16
+ This node creates realistic event streams for testing, development, and
17
+ demonstration of event-driven systems. It supports various event types
18
+ and can generate events with proper sequencing, timestamps, and metadata.
19
+
20
+ Design Philosophy:
21
+ Event sourcing requires consistent, well-structured events with proper
22
+ metadata. This node eliminates the need for DataTransformer with embedded
23
+ Python code by providing a dedicated, configurable event generation
24
+ capability.
25
+
26
+ Upstream Dependencies:
27
+ - Optional configuration nodes
28
+ - Timer/scheduler nodes for periodic generation
29
+ - Template nodes for event schemas
30
+
31
+ Downstream Consumers:
32
+ - Event processing nodes
33
+ - Stream aggregation nodes
34
+ - Event store writers
35
+ - Message queue publishers
36
+ - Analytics and monitoring nodes
37
+
38
+ Configuration:
39
+ - Event types and schemas
40
+ - Generation patterns (burst, continuous, scheduled)
41
+ - Data ranges and distributions
42
+ - Metadata templates
43
+
44
+ Implementation Details:
45
+ - Generates proper event IDs and timestamps
46
+ - Maintains event ordering and sequencing
47
+ - Supports custom event schemas
48
+ - Realistic data generation with configurable patterns
49
+ - Proper metadata structure
50
+
51
+ Error Handling:
52
+ - Validates event schemas
53
+ - Handles invalid configurations gracefully
54
+ - Ensures timestamp consistency
55
+ - Validates required fields
56
+
57
+ Side Effects:
58
+ - No external side effects
59
+ - Deterministic with seed parameter
60
+ - Generates new events on each execution
61
+
62
+ Examples:
63
+ >>> # Generate order events
64
+ >>> generator = EventGeneratorNode(
65
+ ... event_types=['OrderCreated', 'PaymentProcessed', 'OrderShipped'],
66
+ ... event_count=10,
67
+ ... aggregate_prefix='ORDER-2024'
68
+ ... )
69
+ >>> result = generator.execute()
70
+ >>> assert len(result['events']) == 10
71
+ >>> assert result['events'][0]['event_type'] in ['OrderCreated', 'PaymentProcessed', 'OrderShipped']
72
+ >>>
73
+ >>> # Generate user events with custom data
74
+ >>> generator = EventGeneratorNode(
75
+ ... event_types=['UserRegistered', 'UserLoggedIn'],
76
+ ... event_count=5,
77
+ ... custom_data_templates={
78
+ ... 'UserRegistered': {'username': 'user_{id}', 'email': '{username}@example.com'},
79
+ ... 'UserLoggedIn': {'ip_address': '192.168.1.{random_ip}', 'device': 'Chrome/Windows'}
80
+ ... }
81
+ ... )
82
+ >>> result = generator.execute()
83
+ >>> assert 'events' in result
84
+ >>> assert result['metadata']['total_events'] == 5
85
+ """
86
+
87
+ def get_parameters(self) -> Dict[str, NodeParameter]:
88
+ return {
89
+ "event_types": NodeParameter(
90
+ name="event_types",
91
+ type=list,
92
+ required=True,
93
+ description="List of event types to generate",
94
+ ),
95
+ "event_count": NodeParameter(
96
+ name="event_count",
97
+ type=int,
98
+ required=False,
99
+ default=10,
100
+ description="Number of events to generate",
101
+ ),
102
+ "aggregate_prefix": NodeParameter(
103
+ name="aggregate_prefix",
104
+ type=str,
105
+ required=False,
106
+ default="AGG",
107
+ description="Prefix for aggregate IDs",
108
+ ),
109
+ "custom_data_templates": NodeParameter(
110
+ name="custom_data_templates",
111
+ type=dict,
112
+ required=False,
113
+ default={},
114
+ description="Custom data templates for each event type",
115
+ ),
116
+ "source_service": NodeParameter(
117
+ name="source_service",
118
+ type=str,
119
+ required=False,
120
+ default="event-generator",
121
+ description="Source service name for metadata",
122
+ ),
123
+ "time_range_hours": NodeParameter(
124
+ name="time_range_hours",
125
+ type=int,
126
+ required=False,
127
+ default=24,
128
+ description="Time range in hours for event timestamps",
129
+ ),
130
+ "seed": NodeParameter(
131
+ name="seed",
132
+ type=int,
133
+ required=False,
134
+ description="Random seed for reproducible generation",
135
+ ),
136
+ }
137
+
138
+ def run(self, **kwargs) -> Dict[str, Any]:
139
+ event_types = kwargs["event_types"]
140
+ event_count = kwargs.get("event_count", 10)
141
+ aggregate_prefix = kwargs.get("aggregate_prefix", "AGG")
142
+ custom_data_templates = kwargs.get("custom_data_templates", {})
143
+ source_service = kwargs.get("source_service", "event-generator")
144
+ time_range_hours = kwargs.get("time_range_hours", 24)
145
+ seed = kwargs.get("seed")
146
+
147
+ if seed is not None:
148
+ random.seed(seed)
149
+
150
+ # Generate events
151
+ events = []
152
+ now = datetime.now(timezone.utc)
153
+
154
+ # Create a set of aggregate IDs for realistic event grouping
155
+ num_aggregates = max(1, event_count // 3) # Roughly 3 events per aggregate
156
+ aggregate_ids = [
157
+ f"{aggregate_prefix}-{i:04d}" for i in range(1, num_aggregates + 1)
158
+ ]
159
+
160
+ for i in range(event_count):
161
+ # Select event type and aggregate
162
+ event_type = random.choice(event_types)
163
+ aggregate_id = random.choice(aggregate_ids)
164
+
165
+ # Generate timestamp within range
166
+ hours_offset = random.uniform(-time_range_hours, 0)
167
+ event_timestamp = now.timestamp() + hours_offset * 3600
168
+ event_time = datetime.fromtimestamp(event_timestamp, tz=timezone.utc)
169
+
170
+ # Generate event data
171
+ event_data = self._generate_event_data(
172
+ event_type, aggregate_id, custom_data_templates.get(event_type, {})
173
+ )
174
+
175
+ # Create event
176
+ event = {
177
+ "event_id": f"evt-{uuid.uuid4().hex[:8]}",
178
+ "event_type": event_type,
179
+ "aggregate_id": aggregate_id,
180
+ "timestamp": event_time.isoformat() + "Z",
181
+ "data": event_data,
182
+ "metadata": {
183
+ "source": source_service,
184
+ "version": 1,
185
+ "correlation_id": f"corr-{uuid.uuid4().hex[:8]}",
186
+ "generated": True,
187
+ },
188
+ }
189
+ events.append(event)
190
+
191
+ # Sort events by timestamp for realistic ordering
192
+ events.sort(key=lambda x: x["timestamp"])
193
+
194
+ # Generate metadata
195
+ metadata = {
196
+ "total_events": len(events),
197
+ "event_types": list(set(e["event_type"] for e in events)),
198
+ "aggregate_count": len(set(e["aggregate_id"] for e in events)),
199
+ "time_range": {
200
+ "start": events[0]["timestamp"] if events else None,
201
+ "end": events[-1]["timestamp"] if events else None,
202
+ },
203
+ "generated_at": now.isoformat() + "Z",
204
+ "source": source_service,
205
+ }
206
+
207
+ return {
208
+ "events": events,
209
+ "metadata": metadata,
210
+ "event_count": len(events),
211
+ "event_types": metadata["event_types"],
212
+ "aggregate_count": metadata["aggregate_count"],
213
+ }
214
+
215
+ def _generate_event_data(
216
+ self, event_type: str, aggregate_id: str, template: Dict[str, Any]
217
+ ) -> Dict[str, Any]:
218
+ """Generate event-specific data based on type and template."""
219
+
220
+ # Default data generators by event type
221
+ default_generators = {
222
+ "OrderCreated": lambda: {
223
+ "customer_id": f"CUST-{random.randint(100, 999)}",
224
+ "total_amount": round(random.uniform(10.0, 1000.0), 2),
225
+ "item_count": random.randint(1, 5),
226
+ "status": "pending",
227
+ "payment_method": random.choice(
228
+ ["credit_card", "debit_card", "paypal"]
229
+ ),
230
+ },
231
+ "PaymentProcessed": lambda: {
232
+ "payment_id": f"PAY-{random.randint(10000, 99999)}",
233
+ "amount": round(random.uniform(10.0, 1000.0), 2),
234
+ "method": random.choice(["credit_card", "debit_card", "paypal"]),
235
+ "status": random.choice(["success", "failed", "pending"]),
236
+ "transaction_id": f"txn-{uuid.uuid4().hex[:12]}",
237
+ },
238
+ "OrderShipped": lambda: {
239
+ "tracking_number": f"TRACK-{random.randint(100000, 999999)}",
240
+ "carrier": random.choice(["UPS", "FedEx", "DHL", "USPS"]),
241
+ "status": "shipped",
242
+ "estimated_delivery": datetime.now(timezone.utc)
243
+ .replace(day=datetime.now().day + random.randint(1, 7))
244
+ .isoformat()
245
+ + "Z",
246
+ },
247
+ "UserRegistered": lambda: {
248
+ "username": f"user_{random.randint(1000, 9999)}",
249
+ "email": f"user_{random.randint(1000, 9999)}@example.com",
250
+ "plan": random.choice(["free", "premium", "enterprise"]),
251
+ "registration_source": random.choice(["web", "mobile", "api"]),
252
+ },
253
+ "UserLoggedIn": lambda: {
254
+ "ip_address": f"192.168.1.{random.randint(1, 254)}",
255
+ "device": random.choice(
256
+ [
257
+ "Chrome/Windows",
258
+ "Safari/macOS",
259
+ "Firefox/Linux",
260
+ "Mobile/iOS",
261
+ "Mobile/Android",
262
+ ]
263
+ ),
264
+ "session_id": f"sess-{uuid.uuid4().hex[:16]}",
265
+ },
266
+ "SubscriptionCreated": lambda: {
267
+ "plan": random.choice(["basic", "premium", "enterprise"]),
268
+ "price": random.choice([9.99, 29.99, 99.99, 199.99]),
269
+ "billing_cycle": random.choice(["monthly", "yearly"]),
270
+ "trial_days": random.choice([0, 7, 14, 30]),
271
+ },
272
+ }
273
+
274
+ # Use template if provided, otherwise use default generator
275
+ if template:
276
+ data = {}
277
+ for key, value_template in template.items():
278
+ if isinstance(value_template, str):
279
+ # Simple string templating
280
+ data[key] = value_template.format(
281
+ id=random.randint(1, 999),
282
+ random_ip=random.randint(1, 254),
283
+ username=f"user_{random.randint(1000, 9999)}",
284
+ aggregate_id=aggregate_id,
285
+ )
286
+ else:
287
+ data[key] = value_template
288
+ return data
289
+ elif event_type in default_generators:
290
+ return default_generators[event_type]()
291
+ else:
292
+ # Generic event data
293
+ return {
294
+ "event_data": f"Generated data for {event_type}",
295
+ "aggregate_id": aggregate_id,
296
+ "timestamp": datetime.now(timezone.utc).isoformat() + "Z",
297
+ }