diff --git a/tests/tools/test_read_extract.py b/tests/tools/test_read_extract.py
index d96de13ae03f7..83456182215e6 100644
--- a/tests/tools/test_read_extract.py
+++ b/tests/tools/test_read_extract.py
@@ -362,6 +362,111 @@ class TestNotebookExtraction(unittest.TestCase):
with self.assertRaises(ExtractionError):
extract_document_text(p)
+ def test_stream_output_rendered(self):
+ p = os.path.join(self.tmp, "nb_out.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "print('epoch done')",
+ "outputs": [{"output_type": "stream", "name": "stdout",
+ "text": ["epoch done\n", "loss=0.42\n"]}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("Output (cell 1)", text)
+ self.assertIn("loss=0.42", text)
+
+ def test_error_output_keeps_traceback_strips_ansi(self):
+ p = os.path.join(self.tmp, "nb_err.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "1/0",
+ "outputs": [{"output_type": "error", "ename": "ZeroDivisionError",
+ "evalue": "division by zero",
+ "traceback": ["\x1b[31mZeroDivisionError\x1b[0m: division by zero"]}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("Error: ZeroDivisionError: division by zero", text)
+ self.assertNotIn("\x1b", text)
+
+ def test_image_output_replaced_with_placeholder(self):
+ payload = "A" * 4096 # ~3 KB decoded
+ p = os.path.join(self.tmp, "nb_img.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "plot()",
+ "outputs": [{"output_type": "display_data",
+ "data": {"image/png": payload}}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("[image/png output — 3 KB, omitted]", text)
+ self.assertNotIn(payload, text)
+
+ def test_execute_result_prefers_text_plain_over_html(self):
+ p = os.path.join(self.tmp, "nb_df.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "df.head()",
+ "outputs": [{"output_type": "execute_result",
+ "data": {"text/html": "
",
+ "text/plain": " col\n0 1"}}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn(" col", text)
+ self.assertNotIn("", text)
+
+ def test_carriage_return_progress_collapsed(self):
+ p = os.path.join(self.tmp, "nb_tqdm.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "train()",
+ "outputs": [{"output_type": "stream",
+ "text": [" 10%|█\r 50%|█████\r100%|██████████\n"]}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("100%|██████████", text)
+ self.assertNotIn("50%", text)
+
+ def test_widget_output_placeholder(self):
+ p = os.path.join(self.tmp, "nb_widget.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "slider",
+ "outputs": [{"output_type": "display_data",
+ "data": {"application/vnd.jupyter.widget-view+json": {"model_id": "abc"},
+ "text/plain": "IntSlider(value=0)"}}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("[interactive widget — omitted]", text)
+
+ def test_oversized_outputs_truncated(self):
+ from tools.read_extract import _MAX_OUTPUT_CHARS
+ p = os.path.join(self.tmp, "nb_big.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "spam()",
+ "outputs": [{"output_type": "stream",
+ "text": "x" * (_MAX_OUTPUT_CHARS + 5000)}]},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("output chars truncated]", text)
+ self.assertLess(len(text), _MAX_OUTPUT_CHARS + 2000)
+
+ def test_legacy_v3_pyout_flat_fields(self):
+ p = os.path.join(self.tmp, "nb_v3.ipynb")
+ nb = {"worksheets": [{"cells": [
+ {"cell_type": "code", "source": "1+1",
+ "outputs": [{"output_type": "pyout", "text": ["2"]}]},
+ ]}], "nbformat": 3}
+ with open(p, "w") as fh:
+ json.dump(nb, fh)
+ text = extract_document_text(p)
+ self.assertIn("Output (cell 1)", text)
+ self.assertIn("2", text)
+
+ def test_malformed_outputs_ignored(self):
+ p = os.path.join(self.tmp, "nb_bad_out.ipynb")
+ _write_notebook(p, [
+ {"cell_type": "code", "source": "ok()",
+ "outputs": ["not-a-dict", {"output_type": "bogus"}, None]},
+ {"cell_type": "code", "source": "also_ok()", "outputs": "not-a-list"},
+ ])
+ text = extract_document_text(p)
+ self.assertIn("ok()", text)
+ self.assertIn("also_ok()", text)
+ self.assertNotIn("Output (cell", text)
+
# ---------------------------------------------------------------------------
# Word documents (.docx) — #10737
diff --git a/tools/read_extract.py b/tools/read_extract.py
index df09c3def23f2..cc6dd7039189d 100644
--- a/tools/read_extract.py
+++ b/tools/read_extract.py
@@ -16,6 +16,7 @@ import importlib
import json
import os
import posixpath
+import re
import shutil
import subprocess
import tempfile
@@ -386,6 +387,118 @@ def _source_text(source) -> str:
return ""
+def _human_size(n_bytes: int) -> str:
+ return f"{round(n_bytes / 1024)} KB" if n_bytes >= 1024 else f"{n_bytes} B"
+
+
+def _base64_bytes(payload: str) -> int:
+ """Approximate decoded size of a base64 payload (whitespace ignored)."""
+ clean = re.sub(r"[^0-9+/=A-Za-z]", "", payload)
+ padding = min(2, len(clean) - len(clean.rstrip("=")))
+ return max(0, (len(clean) * 3) // 4 - padding)
+
+
+def _clean_stream_text(text: str) -> str:
+ """Strip ANSI escapes and collapse ``\\r`` progress-bar rewrites.
+
+ tqdm and friends redraw the same line via carriage returns; Jupyter
+ renders only the final frame, so keeping the text after the last ``\\r``
+ of each line reproduces what the notebook displays without the invisible
+ intermediate frames.
+ """
+ from tools.ansi_strip import strip_ansi
+
+ cleaned = strip_ansi(text).replace("\r\n", "\n")
+ lines = []
+ for line in cleaned.split("\n"):
+ frames = [frame for frame in line.split("\r") if frame]
+ lines.append(frames[-1] if frames else "")
+ return "\n".join(lines)
+
+
+# Notebook outputs longer than this are tail-truncated per output block so a
+# single runaway training log cannot flood the extracted text.
+_MAX_OUTPUT_CHARS = 20_000
+
+
+def _notebook_output_text(output: Any) -> str:
+ """Render one notebook output as compact text.
+
+ Keeps stream text, error tracebacks, and textual results; replaces
+ token-heavy payloads (base64 images, HTML, widget state) with short
+ sized placeholders. Handles both nbformat v4 output shapes and the
+ legacy v3 ones (``pyout``/``pyerr``; data flat on the output dict).
+ """
+ if not isinstance(output, dict):
+ return ""
+ otype = output.get("output_type")
+
+ if otype == "stream":
+ body = _clean_stream_text(_source_text(output.get("text", "")))
+ return body if body.strip() else ""
+
+ if otype in {"error", "pyerr"}:
+ traceback = output.get("traceback")
+ tb_text = ""
+ if isinstance(traceback, list):
+ tb_text = _clean_stream_text(
+ "\n".join(line for line in traceback if isinstance(line, str))
+ )
+ header = f"Error: {output.get('ename', '')}: {output.get('evalue', '')}".rstrip(": ")
+ return f"{header}\n{tb_text}".rstrip()
+
+ if otype in {"execute_result", "display_data", "pyout"}:
+ data = output.get("data")
+ if not isinstance(data, dict):
+ # nbformat v3 stores mime data flat on the output dict.
+ data = {}
+ if isinstance(output.get("text"), (str, list)):
+ data["text/plain"] = output["text"]
+ for v3_key, mime in (("png", "image/png"), ("jpeg", "image/jpeg"),
+ ("svg", "image/svg+xml"), ("html", "text/html")):
+ if v3_key in output:
+ data[mime] = output[v3_key]
+
+ if "application/vnd.jupyter.widget-view+json" in data:
+ return "[interactive widget — omitted]"
+
+ # Prefer readable text: models consume text/plain (e.g. the pandas
+ # twin of an HTML table) far better than markup.
+ for mime in ("text/plain", "text/markdown"):
+ if mime in data:
+ body = _clean_stream_text(_source_text(data[mime]))
+ if body.strip():
+ return body
+
+ for mime, value in data.items():
+ if isinstance(mime, str) and mime.startswith("image/"):
+ size = _base64_bytes(_source_text(value))
+ return f"[{mime} output — {_human_size(size)}, omitted]"
+
+ if "text/html" in data:
+ html = _source_text(data["text/html"])
+ return f"[text/html output — {len(html):,} chars, omitted]"
+
+ mimes = ", ".join(str(m) for m in data) or "unknown"
+ return f"[{mimes} output — omitted]"
+
+ return ""
+
+
+def _notebook_outputs(cell: dict) -> str:
+ outputs = cell.get("outputs")
+ if not isinstance(outputs, list):
+ return ""
+ blocks = [text for text in (_notebook_output_text(o) for o in outputs) if text]
+ if not blocks:
+ return ""
+ joined = "\n".join(blocks)
+ if len(joined) > _MAX_OUTPUT_CHARS:
+ omitted = len(joined) - _MAX_OUTPUT_CHARS
+ joined = joined[:_MAX_OUTPUT_CHARS] + f"\n… [{omitted:,} output chars truncated]"
+ return joined
+
+
def _extract_notebook(path: str) -> str:
try:
with open(path, encoding="utf-8", errors="replace") as fh:
@@ -418,6 +531,10 @@ def _extract_notebook(path: str) -> str:
counts[typ] += 1
suffix = f" {counts[typ]}" if typ != "raw" else ""
out.extend((f"# ── {labels[typ]} cell{suffix} ──", _source_text(cell.get("source", "")).rstrip("\n"), ""))
+ if typ == "code":
+ rendered = _notebook_outputs(cell)
+ if rendered:
+ out.extend((f"# ── Output (cell {counts[typ]}) ──", rendered.rstrip("\n"), ""))
if not out:
raise ExtractionError("Notebook contains no readable cells")
return "\n".join(out).rstrip("\n") + "\n"