Skip to content

Commit 6462f8e

Browse files
author
Alex Wang
committed
feat: implement event-based assertion system for durable function testing
- Add comprehensive event assertion framework with three categories (STRICT_EQUAL, KEY_EQUAL, IGNORE) - Create CLI event generator tool for capturing execution events as JSON - Update CONTRIBUTING.md - Add events for hello_world example
1 parent 318bc19 commit 6462f8e

File tree

9 files changed

+720
-22
lines changed

9 files changed

+720
-22
lines changed

CONTRIBUTING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ hatch run examples:list
147147
hatch run examples:deploy "Hello World"
148148
```
149149

150+
### Generate Event Files for Testing
151+
```bash
152+
# Generate expected events JSON file for a function
153+
# This runs the function locally and captures execution events
154+
155+
# Basic usage - hello_world example
156+
hatch run examples:generate-events \
157+
--function-module hello_world \
158+
--function-name handler \
159+
--input '"test input"' \
160+
--output examples/events/hello_world_events.json
161+
162+
# Available options:
163+
# --function-module: Python module path (required)
164+
# --function-name: Function name within module (required)
165+
# --input: JSON string input for the function (optional)
166+
# --output: Output path for events JSON file (required)
167+
# --timeout: Execution timeout in seconds (default: 60)
168+
# --verbose: Enable detailed logging
169+
170+
# Use generated events in your tests with the event assertion helper:
171+
# assert_events('events/hello_world_events.json', result.events)
172+
```
173+
150174
### Other CLI Commands
151175
```bash
152176
# Invoke deployed function
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
{
2+
"events": [
3+
{
4+
"event_type": "ExecutionStarted",
5+
"event_timestamp": "2025-12-11T00:32:13.887857+00:00",
6+
"event_id": 1,
7+
"operation_id": "inv-12345678-1234-1234-1234-123456789012",
8+
"name": "execution-name",
9+
"execution_started_details": {
10+
"input": {
11+
"truncated": true
12+
},
13+
"execution_timeout": 60
14+
}
15+
},
16+
{
17+
"event_type": "StepStarted",
18+
"event_timestamp": "2025-12-11T00:32:13.994326+00:00",
19+
"sub_type": "Step",
20+
"event_id": 2,
21+
"operation_id": "1ced8f5be2db23a6513eba4d819c73806424748a7bc6fa0d792cc1c7d1775a97",
22+
"name": "step_1",
23+
"step_started_details": {}
24+
},
25+
{
26+
"event_type": "StepSucceeded",
27+
"event_timestamp": "2025-12-11T00:32:13.994354+00:00",
28+
"sub_type": "Step",
29+
"event_id": 3,
30+
"operation_id": "1ced8f5be2db23a6513eba4d819c73806424748a7bc6fa0d792cc1c7d1775a97",
31+
"name": "step_1",
32+
"step_succeeded_details": {
33+
"result": {
34+
"truncated": true
35+
},
36+
"retry_details": {
37+
"current_attempt": 1,
38+
"next_attempt_delay_seconds": 0
39+
}
40+
}
41+
},
42+
{
43+
"event_type": "WaitStarted",
44+
"event_timestamp": "2025-12-11T00:32:14.099840+00:00",
45+
"sub_type": "Wait",
46+
"event_id": 4,
47+
"operation_id": "c5faca15ac2f93578b39ef4b6bbb871bdedce4ddd584fd31f0bb66fade3947e6",
48+
"wait_started_details": {
49+
"duration": 10,
50+
"scheduled_end_timestamp": "2025-12-11T00:32:24.099828+00:00"
51+
}
52+
},
53+
{
54+
"event_type": "InvocationCompleted",
55+
"event_timestamp": "2025-12-11T00:32:14.205118+00:00",
56+
"event_id": 5
57+
},
58+
{
59+
"event_type": "WaitSucceeded",
60+
"event_timestamp": "2025-12-11T00:32:24.206724+00:00",
61+
"sub_type": "Wait",
62+
"event_id": 6,
63+
"operation_id": "c5faca15ac2f93578b39ef4b6bbb871bdedce4ddd584fd31f0bb66fade3947e6",
64+
"wait_succeeded_details": {
65+
"duration": 10
66+
}
67+
},
68+
{
69+
"event_type": "StepStarted",
70+
"event_timestamp": "2025-12-11T00:32:24.310890+00:00",
71+
"sub_type": "Step",
72+
"event_id": 7,
73+
"operation_id": "6f760b9e9eac89f07ab0223b0f4acb04d1e355d893a1b86a83f4d4b405adee99",
74+
"name": "step_2",
75+
"step_started_details": {}
76+
},
77+
{
78+
"event_type": "StepSucceeded",
79+
"event_timestamp": "2025-12-11T00:32:24.310917+00:00",
80+
"sub_type": "Step",
81+
"event_id": 8,
82+
"operation_id": "6f760b9e9eac89f07ab0223b0f4acb04d1e355d893a1b86a83f4d4b405adee99",
83+
"name": "step_2",
84+
"step_succeeded_details": {
85+
"result": {
86+
"truncated": true
87+
},
88+
"retry_details": {
89+
"current_attempt": 1,
90+
"next_attempt_delay_seconds": 0
91+
}
92+
}
93+
},
94+
{
95+
"event_type": "InvocationCompleted",
96+
"event_timestamp": "2025-12-11T00:32:24.413013+00:00",
97+
"event_id": 9
98+
},
99+
{
100+
"event_type": "ExecutionSucceeded",
101+
"event_timestamp": "2025-12-11T00:32:24.413238+00:00",
102+
"event_id": 10,
103+
"operation_id": "inv-12345678-1234-1234-1234-123456789012",
104+
"name": "execution-name",
105+
"execution_succeeded_details": {
106+
"result": {
107+
"payload": "{\"statusCode\": 200, \"body\": \"Hello from Durable Lambda! (status: 200)\"}",
108+
"truncated": true
109+
}
110+
}
111+
}
112+
]
113+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python3
2+
"""CLI tool for generating event assertion files from durable function executions.
3+
4+
This tool runs durable functions locally and captures their execution events
5+
to generate JSON files that can be used for event-based test assertions.
6+
7+
Usage:
8+
python examples/cli_event_generator.py \
9+
--function-module examples.src.hello_world \
10+
--function-name handler \
11+
--input '{"test": "data"}' \
12+
--output examples/events/hello_world_events.json
13+
"""
14+
15+
import argparse
16+
import importlib
17+
import json
18+
import logging
19+
import sys
20+
from pathlib import Path
21+
from typing import Any
22+
23+
# Add src directories to Python path
24+
examples_dir = Path(__file__).parent
25+
src_dir = examples_dir / "src"
26+
main_src_dir = examples_dir.parent / "src"
27+
28+
for path in [str(src_dir), str(main_src_dir)]:
29+
if path not in sys.path:
30+
sys.path.insert(0, path)
31+
32+
from aws_durable_execution_sdk_python_testing.runner import DurableFunctionTestRunner
33+
34+
35+
logger = logging.getLogger(__name__)
36+
37+
38+
def setup_logging(verbose: bool = False) -> None:
39+
"""Configure logging for the CLI tool."""
40+
level = logging.DEBUG if verbose else logging.INFO
41+
logging.basicConfig(
42+
level=level,
43+
format="%(levelname)s: %(message)s",
44+
handlers=[logging.StreamHandler(sys.stdout)],
45+
)
46+
47+
48+
def import_function(module_name: str, function_name: str) -> Any:
49+
"""Import a function from a module dynamically.
50+
51+
Args:
52+
module_name: Python module path (e.g., 'examples.src.hello_world')
53+
function_name: Function name within the module (e.g., 'handler')
54+
55+
Returns:
56+
The imported function
57+
58+
Raises:
59+
ImportError: If module or function cannot be imported
60+
"""
61+
try:
62+
module = importlib.import_module(module_name)
63+
return getattr(module, function_name)
64+
except ImportError as e:
65+
raise ImportError(f"Failed to import module '{module_name}': {e}") from e
66+
except AttributeError as e:
67+
raise ImportError(
68+
f"Function '{function_name}' not found in module '{module_name}': {e}"
69+
) from e
70+
71+
72+
def serialize_event(event: Any) -> dict:
73+
"""Serialize an Event object to a JSON-serializable dictionary.
74+
75+
Args:
76+
event: Event object to serialize
77+
78+
Returns:
79+
Dictionary representation of the event
80+
"""
81+
# Convert the event to a dictionary, handling datetime objects
82+
event_dict = {}
83+
84+
for field_name, field_value in event.__dict__.items():
85+
if field_value is None:
86+
continue
87+
88+
if hasattr(field_value, "isoformat"): # datetime objects
89+
event_dict[field_name] = field_value.isoformat()
90+
elif hasattr(field_value, "__dict__"): # nested objects
91+
event_dict[field_name] = serialize_nested_object(field_value)
92+
else:
93+
event_dict[field_name] = field_value
94+
95+
return event_dict
96+
97+
98+
def serialize_nested_object(obj: Any) -> dict:
99+
"""Serialize nested objects recursively."""
100+
if obj is None:
101+
return None
102+
103+
result = {}
104+
for field_name, field_value in obj.__dict__.items():
105+
if field_value is None:
106+
continue
107+
108+
if hasattr(field_value, "isoformat"): # datetime objects
109+
result[field_name] = field_value.isoformat()
110+
elif hasattr(field_value, "__dict__"): # nested objects
111+
result[field_name] = serialize_nested_object(field_value)
112+
else:
113+
result[field_name] = field_value
114+
115+
return result
116+
117+
118+
def generate_events_file(
119+
function_module: str,
120+
function_name: str,
121+
input_data: str | None,
122+
output_path: Path,
123+
timeout: int = 60,
124+
) -> None:
125+
"""Generate events file by running the durable function locally.
126+
127+
Args:
128+
function_module: Python module containing the function
129+
function_name: Name of the durable function
130+
input_data: JSON string input for the function
131+
output_path: Path where to save the events JSON file
132+
timeout: Execution timeout in seconds
133+
"""
134+
logger.info(f"Importing function {function_name} from {function_module}")
135+
handler = import_function(function_module, function_name)
136+
137+
logger.info("Running durable function locally...")
138+
with DurableFunctionTestRunner(handler=handler) as runner:
139+
result = runner.run(input=input_data, timeout=timeout)
140+
141+
logger.info(f"Execution completed with status: {result.status}")
142+
logger.info(f"Captured {len(result.events)} events")
143+
144+
# Serialize events to JSON-compatible format
145+
events_data = {"events": [serialize_event(event) for event in result.events]}
146+
147+
# Ensure output directory exists
148+
output_path.parent.mkdir(parents=True, exist_ok=True)
149+
150+
# Write events to JSON file
151+
with open(output_path, "w", encoding="utf-8") as f:
152+
json.dump(events_data, f, indent=2, ensure_ascii=False)
153+
154+
logger.info(f"Events saved to: {output_path}")
155+
156+
157+
def main() -> None:
158+
"""Main CLI entry point."""
159+
parser = argparse.ArgumentParser(
160+
description="Generate event assertion files from durable function executions",
161+
formatter_class=argparse.RawDescriptionHelpFormatter,
162+
epilog="""
163+
Examples:
164+
# Generate events for hello_world example
165+
python examples/cli_event_generator.py \\
166+
--function-module hello_world \\
167+
--function-name handler \\
168+
--input '"test input"' \\
169+
--output examples/events/hello_world_events.json
170+
171+
# Generate events for a function with complex input
172+
python examples/cli_event_generator.py \\
173+
--function-module step.step_with_retry \\
174+
--function-name handler \\
175+
--input '{"retries": 3, "data": "test"}' \\
176+
--output examples/events/step_with_retry_events.json
177+
""",
178+
)
179+
180+
parser.add_argument(
181+
"--function-module",
182+
required=True,
183+
help="Python module containing the durable function (e.g., 'hello_world' or 'step.step_with_retry')",
184+
)
185+
186+
parser.add_argument(
187+
"--function-name",
188+
required=True,
189+
help="Name of the durable function within the module (e.g., 'handler')",
190+
)
191+
192+
parser.add_argument(
193+
"--input", help="JSON string input for the function (default: None)"
194+
)
195+
196+
parser.add_argument(
197+
"--output",
198+
type=Path,
199+
required=True,
200+
help="Output path for the events JSON file",
201+
)
202+
203+
parser.add_argument(
204+
"--timeout",
205+
type=int,
206+
default=60,
207+
help="Execution timeout in seconds (default: 60)",
208+
)
209+
210+
parser.add_argument(
211+
"--verbose", "-v", action="store_true", help="Enable verbose logging"
212+
)
213+
214+
args = parser.parse_args()
215+
216+
setup_logging(args.verbose)
217+
218+
try:
219+
generate_events_file(
220+
function_module=args.function_module,
221+
function_name=args.function_name,
222+
input_data=args.input,
223+
output_path=args.output,
224+
timeout=args.timeout,
225+
)
226+
logger.info("Event generation completed successfully!")
227+
228+
except Exception as e:
229+
logger.error(f"Event generation failed: {e}")
230+
if args.verbose:
231+
logger.exception("Full traceback:")
232+
sys.exit(1)
233+
234+
235+
if __name__ == "__main__":
236+
main()

0 commit comments

Comments
 (0)