Skip to content

Commit 7c2aa85

Browse files
feat(reporting): wrap reporters with error handling and stream HTML rows to disk
- Add ReporterErrorHandler to log failures and skip a reporter after the first error - Wrap each child in CompositeReporter with ReporterErrorHandler - Stream SimpleHtmlReporter message rows to a temp file to avoid holding all base64 screenshots in memory; assemble the final HTML in generate()
1 parent 927aa6d commit 7c2aa85

1 file changed

Lines changed: 152 additions & 48 deletions

File tree

src/askui/reporting.py

Lines changed: 152 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import base64
44
import io
55
import json
6+
import logging
67
import platform
78
import random
9+
import shutil
810
import sys
911
from abc import ABC, abstractmethod
1012
from datetime import datetime, timezone
@@ -18,6 +20,8 @@
1820

1921
from askui.utils.annotated_image import AnnotatedImage
2022

23+
logger = logging.getLogger(__name__)
24+
2125
if TYPE_CHECKING:
2226
from PIL import Image
2327

@@ -156,6 +160,70 @@ def generate(self) -> None:
156160
NULL_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+
159227
class 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):
211281
class 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

11551259
class AllureReporter(Reporter):

0 commit comments

Comments
 (0)