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
@@ -156,6 +160,70 @@ def generate(self) -> None:
156160NULL_REPORTER = NullReporter ()
157161
158162
163+ class ReporterErrorHandler (Reporter ):
164+ """A reporter that handles errors by logging them and skipping the reporter."""
165+
166+ def __init__ (self , reporter : Reporter ) -> None :
167+ self ._reporter = reporter
168+ self ._error_occurred = False
169+
170+ def _log_on_exception (self , error : Exception ) -> None :
171+ error_message = getattr (error , "message" , str (error ))
172+ logger .exception (
173+ "Skipping the usage of reporter %s due to the following exception: %s" ,
174+ self ._reporter ,
175+ error_message ,
176+ )
177+ self ._error_occurred = True
178+
179+ @override
180+ def add_message (
181+ self ,
182+ role : str ,
183+ content : Union [str , dict [str , Any ], list [Any ]],
184+ image : Optional [Image .Image | list [Image .Image ] | AnnotatedImage ] = None ,
185+ ) -> None :
186+ if self ._error_occurred :
187+ logger .debug ("Skipping reporter due to previous error" )
188+ return
189+ try :
190+ self ._reporter .add_message (role , content , image )
191+ except Exception as e : # noqa: BLE001
192+ self ._log_on_exception (e )
193+
194+ @override
195+ def add_usage_summary (self , usage : UsageSummary ) -> None :
196+ if self ._error_occurred :
197+ logger .debug ("Skipping reporter due to previous error" )
198+ return
199+ try :
200+ self ._reporter .add_usage_summary (usage )
201+ except Exception as e : # noqa: BLE001
202+ self ._log_on_exception (e )
203+
204+ @override
205+ def add_cache_execution_statistics (
206+ self , original_usage : dict [str , int | None ]
207+ ) -> None :
208+ if self ._error_occurred :
209+ logger .debug ("Skipping reporter due to previous error" )
210+ return
211+ try :
212+ self ._reporter .add_cache_execution_statistics (original_usage )
213+ except Exception as e : # noqa: BLE001
214+ self ._log_on_exception (e )
215+
216+ @override
217+ def generate (self ) -> None :
218+ if self ._error_occurred :
219+ logger .debug ("Skipping reporter due to previous error" )
220+ return
221+ try :
222+ self ._reporter .generate ()
223+ except Exception as e : # noqa: BLE001
224+ self ._log_on_exception (e )
225+
226+
159227class CompositeReporter (Reporter ):
160228 """A reporter that combines multiple reporters.
161229
@@ -168,7 +236,9 @@ class CompositeReporter(Reporter):
168236 """
169237
170238 def __init__ (self , reporters : list [Reporter ] | None = None ) -> None :
171- self ._reporters = reporters or []
239+ self ._reporters = [
240+ ReporterErrorHandler (reporter ) for reporter in reporters or []
241+ ]
172242
173243 @override
174244 def add_message (
@@ -211,14 +281,18 @@ class SystemInfo(TypedDict):
211281class SimpleHtmlReporter (Reporter ):
212282 """A reporter that generates HTML reports with conversation logs and system information.
213283
284+ Messages are streamed to a temporary file as they arrive so that base64-encoded
285+ screenshots are never held in memory all at once. The final report is assembled
286+ as a single self-contained HTML file on `generate()`.
287+
214288 Args:
215289 report_dir (str, optional): Directory where reports will be saved.
216290 Defaults to `reports`.
217291 """
218292
219293 def __init__ (self , report_dir : str = "reports" ) -> None :
220294 self .report_dir = Path (report_dir )
221- self .messages : list [ dict [ str , Any ]] = []
295+ self ._temp_messages_file : Path | None = None
222296 self .system_info = self ._collect_system_info ()
223297 self .usage_summary : UsageSummary | None = None
224298 self .cache_original_usage : dict [str , int | None ] | None = None
@@ -244,29 +318,76 @@ def _format_content(self, content: Union[str, dict[str, Any], list[Any]]) -> str
244318 return json .dumps (content , indent = 2 )
245319 return str (content )
246320
321+ def _get_temp_messages_file (self ) -> Path :
322+ """Return the path to the temporary messages file, creating it if needed."""
323+ if self ._temp_messages_file is None :
324+ self .report_dir .mkdir (parents = True , exist_ok = True )
325+ self ._temp_messages_file = self .report_dir / f"_messages_{ id (self )} .tmp"
326+ return self ._temp_messages_file
327+
328+ def _render_message_row (
329+ self ,
330+ timestamp : datetime ,
331+ role : str ,
332+ content : str ,
333+ is_json : bool ,
334+ images : list [str ],
335+ ) -> str :
336+ """Render a single conversation message as an HTML table row."""
337+ role_lower = role .lower ()
338+ ts_str = timestamp .strftime ("%H:%M:%S.%f" )[:- 3 ]
339+
340+ if is_json :
341+ content_html = (
342+ f'<div class="json-content">'
343+ f'<pre><code class="json">{ content } </code></pre>'
344+ f"</div>"
345+ )
346+ else :
347+ content_html = content
348+
349+ images_html = "" .join (
350+ f'<br><img src="data:image/png;base64,{ image } "'
351+ f' class="message-image" alt="Message image">'
352+ for image in images
353+ )
354+
355+ return (
356+ f'<tr class="{ role_lower } ">'
357+ f'<td class="timestamp">{ ts_str } UTC</td>'
358+ f'<td><span class="role-badge role-{ role_lower } ">{ role } </span></td>'
359+ f'<td class="content-cell">{ content_html } { images_html } </td>'
360+ f"</tr>\n "
361+ )
362+
247363 @override
248364 def add_message (
249365 self ,
250366 role : str ,
251367 content : Union [str , dict [str , Any ], list [Any ]],
252368 image : Optional [Image .Image | list [Image .Image ] | AnnotatedImage ] = None ,
253369 ) -> None :
254- """Add a message to the report."""
255- # Track start time from first message
370+ """Add a message to the report.
371+
372+ The rendered HTML row is written directly to a temporary file so that
373+ base64 image data is not accumulated in memory during long runs.
374+ """
256375 if self ._start_time is None :
257376 self ._start_time = datetime .now (tz = timezone .utc )
258377
259378 _images = normalize_to_pil_images (image )
260379 _content = truncate_content (content )
261380
262- message = {
263- "timestamp" : datetime .now (tz = timezone .utc ),
264- "role" : role ,
265- "content" : self ._format_content (_content ),
266- "is_json" : isinstance (_content , (dict , list )),
267- "images" : [self ._image_to_base64 (img ) for img in _images ],
268- }
269- self .messages .append (message )
381+ timestamp = datetime .now (tz = timezone .utc )
382+ formatted_content = self ._format_content (_content )
383+ is_json = isinstance (_content , (dict , list ))
384+ image_b64s = [self ._image_to_base64 (img ) for img in _images ]
385+
386+ row_html = self ._render_message_row (
387+ timestamp , role , formatted_content , is_json , image_b64s
388+ )
389+ with self ._get_temp_messages_file ().open (mode = "a" , encoding = "utf-8" ) as f :
390+ f .write (row_html )
270391
271392 @override
272393 def add_usage_summary (self , usage : UsageSummary ) -> None :
@@ -299,8 +420,11 @@ def generate(self) -> None:
299420 - System information
300421 - All collected messages with their content and images
301422 - Syntax-highlighted JSON content
423+
424+ Message rows are streamed from a temporary file so that the full set of
425+ base64 images is never held in memory simultaneously.
302426 """
303- template_str = """
427+ _HEADER_TEMPLATE = """
304428 <!DOCTYPE html>
305429 <html lang="en">
306430 <head>
@@ -1092,39 +1216,9 @@ def generate(self) -> None:
10921216 <th>Role</th>
10931217 <th>Content</th>
10941218 </tr>
1095- {% for msg in messages %}
1096- <tr class="{{ msg.role.lower() }}">
1097- <td class="timestamp">{{ msg.timestamp.strftime('%H:%M:%S.%f')[:-3] }} UTC</td>
1098- <td>
1099- <span class="role-badge role-{{ msg.role.lower() }}">
1100- {{ msg.role }}
1101- </span>
1102- </td>
1103- <td class="content-cell">
1104- {% if msg.is_json %}
1105- <div class="json-content">
1106- <pre><code class="json">{{ msg.content }}</code></pre>
1107- </div>
1108- {% else %}
1109- {{ msg.content }}
1110- {% endif %}
1111- {% for image in msg.images %}
1112- <br>
1113- <img src="data:image/png;base64,{{ image }}"
1114- class="message-image"
1115- alt="Message image">
1116- {% endfor %}
1117- </td>
1118- </tr>
1119- {% endfor %}
1120- </table>
1121- </div>
1122- </div>
1123- </body>
1124- </html>
11251219 """
11261220
1127- template = Template ( template_str )
1221+ _FOOTER = " </table> \n </div> \n </div> \n </body> \n </html>"
11281222
11291223 # Calculate execution time
11301224 end_time = datetime .now (tz = timezone .utc )
@@ -1135,21 +1229,31 @@ def generate(self) -> None:
11351229 minutes , secs = divmod (remainder , 60 )
11361230 execution_time_formatted = f"{ hours :02d} :{ minutes :02d} :{ secs :02d} "
11371231
1138- html = template .render (
1232+ header_html = Template ( _HEADER_TEMPLATE ) .render (
11391233 timestamp = end_time ,
1140- messages = self .messages ,
11411234 system_info = self .system_info ,
11421235 usage_summary = self .usage_summary ,
11431236 cache_original_usage = self .cache_original_usage ,
11441237 execution_time_formatted = execution_time_formatted ,
11451238 )
11461239
11471240 report_path = (
1148- self .report_dir / f"report_{ datetime . now ( tz = timezone . utc ) :%Y%m%d%H%M%S%f} "
1241+ self .report_dir / f"report_{ end_time :%Y%m%d%H%M%S%f} "
11491242 f"{ random .randint (0 , 1000 ):03} .html"
11501243 )
11511244 self .report_dir .mkdir (parents = True , exist_ok = True )
1152- report_path .write_text (html , encoding = "utf-8" )
1245+
1246+ with report_path .open (mode = "w" , encoding = "utf-8" ) as out :
1247+ out .write (header_html )
1248+ if (
1249+ self ._temp_messages_file is not None
1250+ and self ._temp_messages_file .exists ()
1251+ ):
1252+ with self ._temp_messages_file .open (mode = "r" , encoding = "utf-8" ) as tmp :
1253+ shutil .copyfileobj (tmp , out )
1254+ self ._temp_messages_file .unlink ()
1255+ self ._temp_messages_file = None
1256+ out .write (_FOOTER )
11531257
11541258
11551259class AllureReporter (Reporter ):
0 commit comments