33import base64
44import io
55import json
6+ import logging
67import platform
78import random
9+ import shutil
810import sys
911from abc import ABC , abstractmethod
1012from datetime import datetime , timezone
1820
1921from askui .utils .annotated_image import AnnotatedImage
2022
23+ logger = logging .getLogger (__name__ )
24+
2125if TYPE_CHECKING :
2226 from PIL import Image
2327
@@ -188,6 +192,70 @@ def generate(self) -> None:
188192NULL_REPORTER = NullReporter ()
189193
190194
195+ class ReporterErrorHandler (Reporter ):
196+ """A reporter that handles errors by logging them and skipping the reporter."""
197+
198+ def __init__ (self , reporter : Reporter ) -> None :
199+ self ._reporter = reporter
200+ self ._error_occurred = False
201+
202+ def _log_on_exception (self , error : Exception ) -> None :
203+ error_message = getattr (error , "message" , str (error ))
204+ logger .exception (
205+ "Skipping the usage of reporter %s due to the following exception: %s" ,
206+ self ._reporter ,
207+ error_message ,
208+ )
209+ self ._error_occurred = True
210+
211+ @override
212+ def add_message (
213+ self ,
214+ role : str ,
215+ content : Union [str , dict [str , Any ], list [Any ]],
216+ image : Optional [Image .Image | list [Image .Image ] | AnnotatedImage ] = None ,
217+ ) -> None :
218+ if self ._error_occurred :
219+ logger .debug ("Skipping reporter due to previous error" )
220+ return
221+ try :
222+ self ._reporter .add_message (role , content , image )
223+ except Exception as e : # noqa: BLE001
224+ self ._log_on_exception (e )
225+
226+ @override
227+ def add_usage_summary (self , usage : UsageSummary ) -> None :
228+ if self ._error_occurred :
229+ logger .debug ("Skipping reporter due to previous error" )
230+ return
231+ try :
232+ self ._reporter .add_usage_summary (usage )
233+ except Exception as e : # noqa: BLE001
234+ self ._log_on_exception (e )
235+
236+ @override
237+ def add_cache_execution_statistics (
238+ self , original_usage : dict [str , int | None ]
239+ ) -> None :
240+ if self ._error_occurred :
241+ logger .debug ("Skipping reporter due to previous error" )
242+ return
243+ try :
244+ self ._reporter .add_cache_execution_statistics (original_usage )
245+ except Exception as e : # noqa: BLE001
246+ self ._log_on_exception (e )
247+
248+ @override
249+ def generate (self ) -> None :
250+ if self ._error_occurred :
251+ logger .debug ("Skipping reporter due to previous error" )
252+ return
253+ try :
254+ self ._reporter .generate ()
255+ except Exception as e : # noqa: BLE001
256+ self ._log_on_exception (e )
257+
258+
191259class CompositeReporter (Reporter ):
192260 """A reporter that combines multiple reporters.
193261
@@ -200,7 +268,9 @@ class CompositeReporter(Reporter):
200268 """
201269
202270 def __init__ (self , reporters : list [Reporter ] | None = None ) -> None :
203- self ._reporters = reporters or []
271+ self ._reporters = [
272+ ReporterErrorHandler (reporter ) for reporter in reporters or []
273+ ]
204274
205275 @override
206276 def add_message (
@@ -243,14 +313,18 @@ class SystemInfo(TypedDict):
243313class SimpleHtmlReporter (Reporter ):
244314 """A reporter that generates HTML reports with conversation logs and system information.
245315
316+ Messages are streamed to a temporary file as they arrive so that base64-encoded
317+ screenshots are never held in memory all at once. The final report is assembled
318+ as a single self-contained HTML file on `generate()`.
319+
246320 Args:
247321 report_dir (str, optional): Directory where reports will be saved.
248322 Defaults to `reports`.
249323 """
250324
251325 def __init__ (self , report_dir : str = "reports" ) -> None :
252326 self .report_dir = Path (report_dir )
253- self .messages : list [ dict [ str , Any ]] = []
327+ self ._temp_messages_file : Path | None = None
254328 self .system_info = self ._collect_system_info ()
255329 self .usage_summary : UsageSummary | None = None
256330 self .cache_original_usage : dict [str , int | None ] | None = None
@@ -276,29 +350,82 @@ def _format_content(self, content: Union[str, dict[str, Any], list[Any]]) -> str
276350 return json .dumps (content , indent = 2 )
277351 return str (content )
278352
353+ def _get_temp_messages_file (self ) -> Path :
354+ """Return the path to the temporary messages file, creating it if needed."""
355+ if self ._temp_messages_file is None or not self ._temp_messages_file .exists ():
356+ self .report_dir .mkdir (parents = True , exist_ok = True )
357+ _report_ts = datetime .now (tz = timezone .utc ).strftime ("%Y%m%d_%H%M%S_%f" )
358+ self ._temp_messages_file = (
359+ self .report_dir / f"AskUI_report_{ _report_ts } .tmp"
360+ )
361+ return self ._temp_messages_file
362+
363+ _MESSAGE_ROW_TEMPLATE = Template (
364+ '<tr class="{{ role_lower }}">'
365+ '<td class="timestamp">{{ ts_str }} UTC</td>'
366+ '<td><span class="role-badge role-{{ role_lower }}">{{ role }}</span></td>'
367+ '<td class="content-cell">'
368+ "{% if is_json %}"
369+ '<div class="json-content">'
370+ '<pre><code class="json">{{ content }}</code></pre>'
371+ "</div>"
372+ "{% else %}"
373+ "{{ content }}"
374+ "{% endif %}"
375+ "{% for image in images %}"
376+ '<br><img src="data:image/png;base64,{{ image }}" '
377+ 'class="message-image" alt="Message image">'
378+ "{% endfor %}"
379+ "</td>"
380+ "</tr>\n "
381+ )
382+
383+ def _render_message_row (
384+ self ,
385+ timestamp : datetime ,
386+ role : str ,
387+ content : str ,
388+ is_json : bool ,
389+ images : list [str ],
390+ ) -> str :
391+ """Render a single conversation message as an HTML table row."""
392+ return self ._MESSAGE_ROW_TEMPLATE .render (
393+ role_lower = role .lower (),
394+ ts_str = timestamp .strftime ("%H:%M:%S.%f" )[:- 3 ],
395+ role = role ,
396+ content = content ,
397+ is_json = is_json ,
398+ images = images ,
399+ )
400+
279401 @override
280402 def add_message (
281403 self ,
282404 role : str ,
283405 content : Union [str , dict [str , Any ], list [Any ]],
284406 image : Optional [Image .Image | list [Image .Image ] | AnnotatedImage ] = None ,
285407 ) -> None :
286- """Add a message to the report."""
287- # Track start time from first message
408+ """Add a message to the report.
409+
410+ The rendered HTML row is written directly to a temporary file so that
411+ base64 image data is not accumulated in memory during long runs.
412+ """
288413 if self ._start_time is None :
289414 self ._start_time = datetime .now (tz = timezone .utc )
290415
291416 _images = normalize_to_pil_images (image )
292417 _content = truncate_base64_images (content )
293418
294- message = {
295- "timestamp" : datetime .now (tz = timezone .utc ),
296- "role" : role ,
297- "content" : self ._format_content (_content ),
298- "is_json" : isinstance (_content , (dict , list )),
299- "images" : [self ._image_to_base64 (img ) for img in _images ],
300- }
301- self .messages .append (message )
419+ timestamp = datetime .now (tz = timezone .utc )
420+ formatted_content = self ._format_content (_content )
421+ is_json = isinstance (_content , (dict , list ))
422+ image_b64s = [self ._image_to_base64 (img ) for img in _images ]
423+
424+ row_html = self ._render_message_row (
425+ timestamp , role , formatted_content , is_json , image_b64s
426+ )
427+ with self ._get_temp_messages_file ().open (mode = "a" , encoding = "utf-8" ) as f :
428+ f .write (row_html )
302429
303430 @override
304431 def add_usage_summary (self , usage : UsageSummary ) -> None :
@@ -331,8 +458,11 @@ def generate(self) -> None:
331458 - System information
332459 - All collected messages with their content and images
333460 - Syntax-highlighted JSON content
461+
462+ Message rows are streamed from a temporary file so that the full set of
463+ base64 images is never held in memory simultaneously.
334464 """
335- template_str = """
465+ _HEADER_TEMPLATE = """
336466 <!DOCTYPE html>
337467 <html lang="en">
338468 <head>
@@ -1137,39 +1267,9 @@ def generate(self) -> None:
11371267 <th>Role</th>
11381268 <th>Content</th>
11391269 </tr>
1140- {% for msg in messages %}
1141- <tr class="{{ msg.role.lower() }}">
1142- <td class="timestamp">{{ msg.timestamp.strftime('%H:%M:%S.%f')[:-3] }} UTC</td>
1143- <td>
1144- <span class="role-badge role-{{ msg.role.lower() }}">
1145- {{ msg.role }}
1146- </span>
1147- </td>
1148- <td class="content-cell">
1149- {% if msg.is_json %}
1150- <div class="json-content">
1151- <pre><code class="json">{{ msg.content }}</code></pre>
1152- </div>
1153- {% else %}
1154- {{ msg.content }}
1155- {% endif %}
1156- {% for image in msg.images %}
1157- <br>
1158- <img src="data:image/png;base64,{{ image }}"
1159- class="message-image"
1160- alt="Message image">
1161- {% endfor %}
1162- </td>
1163- </tr>
1164- {% endfor %}
1165- </table>
1166- </div>
1167- </div>
1168- </body>
1169- </html>
11701270 """
11711271
1172- template = Template ( template_str )
1272+ _FOOTER = " </table> \n </div> \n </div> \n </body> \n </html>"
11731273
11741274 # Calculate execution time
11751275 end_time = datetime .now (tz = timezone .utc )
@@ -1198,9 +1298,8 @@ def _format_conversation_duration(
11981298 ).total_seconds ()
11991299 )
12001300
1201- html = template .render (
1301+ header_html = Template ( _HEADER_TEMPLATE ) .render (
12021302 timestamp = end_time ,
1203- messages = self .messages ,
12041303 system_info = self .system_info ,
12051304 usage_summary = self .usage_summary ,
12061305 cache_original_usage = self .cache_original_usage ,
@@ -1209,11 +1308,26 @@ def _format_conversation_duration(
12091308 )
12101309
12111310 report_path = (
1212- self .report_dir / f"report_{ datetime . now ( tz = timezone . utc ) :%Y%m%d%H%M%S%f} "
1311+ self .report_dir / f"report_{ end_time :%Y%m%d%H%M%S%f} "
12131312 f"{ random .randint (0 , 1000 ):03} .html"
12141313 )
12151314 self .report_dir .mkdir (parents = True , exist_ok = True )
1216- report_path .write_text (html , encoding = "utf-8" )
1315+
1316+ with report_path .open (mode = "w" , encoding = "utf-8" ) as out :
1317+ out .write (header_html )
1318+ try :
1319+ if (
1320+ self ._temp_messages_file is not None
1321+ and self ._temp_messages_file .exists ()
1322+ ):
1323+ with self ._temp_messages_file .open (
1324+ mode = "r" , encoding = "utf-8"
1325+ ) as tmp :
1326+ shutil .copyfileobj (tmp , out )
1327+ self ._temp_messages_file .unlink ()
1328+ self ._temp_messages_file = None
1329+ finally :
1330+ out .write (_FOOTER )
12171331
12181332
12191333class AllureReporter (Reporter ):
0 commit comments