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 (f"Function '{ function_name } ' not found in module '{ module_name } ': { e } " ) from e
68+
69+
70+ def serialize_event (event : Any ) -> dict :
71+ """Serialize an Event object to a JSON-serializable dictionary.
72+
73+ Args:
74+ event: Event object to serialize
75+
76+ Returns:
77+ Dictionary representation of the event
78+ """
79+ # Convert the event to a dictionary, handling datetime objects
80+ event_dict = {}
81+
82+ for field_name , field_value in event .__dict__ .items ():
83+ if field_value is None :
84+ continue
85+
86+ if hasattr (field_value , 'isoformat' ): # datetime objects
87+ event_dict [field_name ] = field_value .isoformat ()
88+ elif hasattr (field_value , '__dict__' ): # nested objects
89+ event_dict [field_name ] = serialize_nested_object (field_value )
90+ else :
91+ event_dict [field_name ] = field_value
92+
93+ return event_dict
94+
95+
96+ def serialize_nested_object (obj : Any ) -> dict :
97+ """Serialize nested objects recursively."""
98+ if obj is None :
99+ return None
100+
101+ result = {}
102+ for field_name , field_value in obj .__dict__ .items ():
103+ if field_value is None :
104+ continue
105+
106+ if hasattr (field_value , 'isoformat' ): # datetime objects
107+ result [field_name ] = field_value .isoformat ()
108+ elif hasattr (field_value , '__dict__' ): # nested objects
109+ result [field_name ] = serialize_nested_object (field_value )
110+ else :
111+ result [field_name ] = field_value
112+
113+ return result
114+
115+
116+ def generate_events_file (
117+ function_module : str ,
118+ function_name : str ,
119+ input_data : str | None ,
120+ output_path : Path ,
121+ timeout : int = 60
122+ ) -> None :
123+ """Generate events file by running the durable function locally.
124+
125+ Args:
126+ function_module: Python module containing the function
127+ function_name: Name of the durable function
128+ input_data: JSON string input for the function
129+ output_path: Path where to save the events JSON file
130+ timeout: Execution timeout in seconds
131+ """
132+ logger .info (f"Importing function { function_name } from { function_module } " )
133+ handler = import_function (function_module , function_name )
134+
135+ logger .info ("Running durable function locally..." )
136+ with DurableFunctionTestRunner (handler = handler ) as runner :
137+ result = runner .run (input = input_data , timeout = timeout )
138+
139+ logger .info (f"Execution completed with status: { result .status } " )
140+ logger .info (f"Captured { len (result .events )} events" )
141+
142+ # Serialize events to JSON-compatible format
143+ events_data = {
144+ "events" : [serialize_event (event ) for event in result .events ]
145+ }
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" ,
194+ help = "JSON string input for the function (default: None)"
195+ )
196+
197+ parser .add_argument (
198+ "--output" ,
199+ type = Path ,
200+ required = True ,
201+ help = "Output path for the events JSON file"
202+ )
203+
204+ parser .add_argument (
205+ "--timeout" ,
206+ type = int ,
207+ default = 60 ,
208+ help = "Execution timeout in seconds (default: 60)"
209+ )
210+
211+ parser .add_argument (
212+ "--verbose" , "-v" ,
213+ action = "store_true" ,
214+ help = "Enable verbose logging"
215+ )
216+
217+ args = parser .parse_args ()
218+
219+ setup_logging (args .verbose )
220+
221+ try :
222+ generate_events_file (
223+ function_module = args .function_module ,
224+ function_name = args .function_name ,
225+ input_data = args .input ,
226+ output_path = args .output ,
227+ timeout = args .timeout
228+ )
229+ logger .info ("Event generation completed successfully!" )
230+
231+ except Exception as e :
232+ logger .error (f"Event generation failed: { e } " )
233+ if args .verbose :
234+ logger .exception ("Full traceback:" )
235+ sys .exit (1 )
236+
237+
238+ if __name__ == "__main__" :
239+ main ()
0 commit comments