fix: add html2text + Rich render_display_data() for display_data output (#58)

Previously text/html display_data output was either ignored (log) or printed raw. Now: - New dependency: html2text (>=2024.2.26) - New util: render_display_data() in utils.py — returns Rich renderables (Markdown or Text) using priority text/markdown > text/html > text/plain. text/html is converted via html2text; text/plain is wrapped with Text.from_ansi to handle embedded ANSI escapes. - All three call sites (exec.py, automation.py, repl.py) refactored to use the shared function instead of duplicated if/elif chains. - Each module uses a shared Console instance (_console / self.console) to avoid re-allocating per-output during streaming. - Removed direct html2text and Markdown imports from the three call sites — they now just pass the renderable to Console.print(). - Tests in test_utils.py cover Markdown return, Text return, priority, and no-text fallback.

Merlin Ran committed Jun 16, 2026 at 14:25 UTC c58a851bba025e9cdbf9e66613efa401020853db
7 files changed +89 -18
pyproject.toml
+1
@@ -27,6 +27,7 @@ dependencies = [
27 "filelock>=3.29.2",
28 "google-auth>=2.49.1",
29 "google-auth-oauthlib>=1.3.0",
30 + "html2text>=2024.2.26",
31 "jupyter-kernel-client",
32 "nbformat>=5.10.4",
33 "packaging>=24.0",
src/colab_cli/commands/automation.py
+8 -3
@@ -18,12 +18,15 @@ import sys
18 import json
19 from typing import Optional, List
20 import typer
21 +from rich.console import Console
22 from typing_extensions import Annotated
23
24 from colab_cli.runtime import ColabRuntime
25 from colab_cli.contents import ContentsClient
26 from colab_cli.auth import get_credentials
26 -from colab_cli.utils import get_status_code
27 +from colab_cli.utils import get_status_code, render_display_data
28 +
29 +_console = Console()
30
31
32 # Default execute() timeout for human-in-the-loop automations (auth /
@@ -35,6 +38,7 @@ from colab_cli.utils import get_status_code
38 INTERACTIVE_AUTOMATION_TIMEOUT_SEC = 600
39
40
41 +
42 def run_automation(
43 name: str,
44 op: str,
@@ -153,8 +157,9 @@ def run_automation(
157 if "text" in out:
158 sys.stdout.write(out["text"])
159 elif "data" in out:
156 - if "text/plain" in out["data"]:
157 - typer.echo(out["data"]["text/plain"])
160 + text = render_display_data(out["data"])
161 + if text is not None:
162 + _console.print(text)
163 elif out.get("output_type") == "error":
164 ename = out.get("ename", "Error")
165 evalue = out.get("evalue", "")
src/colab_cli/commands/execution.py
+8 -3
@@ -20,13 +20,16 @@ import sys
20 import typer
21 import uuid
22 from nbformat.v4 import new_output
23 +from rich.console import Console
24 from typing import Optional
25 from typing_extensions import Annotated
26
27 from colab_cli.runtime import ColabRuntime
27 -from colab_cli.utils import handle_image, is_terminal_error
28 +from colab_cli.utils import handle_image, is_terminal_error, render_display_data
29 from colab_cli.console import connect_console
30
31 +_console = Console()
32 +
33 TITLE_REGEX = re.compile(r"^\s*#\s*@title\s+(.*)", re.MULTILINE)
34
35
@@ -72,6 +75,7 @@ def save_output(outputs, cell):
75 )
76
77
78 +
79 def display_output(out, output_image=None):
80 if out.get("output_type") == "stream":
81 stream = sys.stderr if out.get("name") == "stderr" else sys.stdout
@@ -79,8 +83,9 @@ def display_output(out, output_image=None):
83 stream.flush()
84 elif "data" in out:
85 data = out["data"]
82 - if text := data.get("text/plain"):
83 - typer.echo(text)
86 + text = render_display_data(data)
87 + if text is not None:
88 + _console.print(text)
89 if png := data.get("image/png"):
90 handle_image(png, "image/png", target_path=output_image)
91 elif jpeg := data.get("image/jpeg"):
src/colab_cli/repl.py
+11 -10
@@ -25,9 +25,8 @@ from rich.console import Console
25 from rich.text import Text
26
27 from colab_cli.runtime import ColabRuntime
28 -from colab_cli.utils import handle_image
28 +from colab_cli.utils import handle_image, render_display_data
29
30 -console = Console()
30
31
32 class ColabREPL:
@@ -43,7 +42,7 @@ class ColabREPL:
42 self.history_logger = history_logger
43 self.output_image = output_image
44 self.kb = KeyBindings()
46 - self.console = console
45 + self.console = Console()
46 self.repl_history: List[dict] = []
47
48 @self.kb.add("enter")
@@ -91,14 +90,16 @@ class ColabREPL:
90 image_displayed = True
91 break
92
94 - if "text/plain" in data:
95 - text = data["text/plain"]
93 + text = render_display_data(data)
94 + if text is not None:
95 # Skip generic IPython object reprs if we already showed an image
97 - if image_displayed and any(
98 - x in text for x in ["<IPython.core.display.Image", "<Figure size"]
99 - ):
100 - return
101 - self.console.print(Text.from_ansi(text))
96 + if isinstance(text, Text) and image_displayed:
97 + if any(
98 + x in text.plain
99 + for x in ["<IPython.core.display.Image", "<Figure size"]
100 + ):
101 + return
102 + self.console.print(text)
103 elif output.get("output_type") == "error":
104 ename = output.get("ename", "Error")
105 evalue = output.get("evalue", "")
src/colab_cli/utils.py
+20 -1
@@ -13,12 +13,15 @@
13 # limitations under the License.
14
15 import base64
16 +import html2text
17 import logging
18 import sys
19 import tempfile
20
21 +from typing import Optional, Union
22
21 -from typing import Optional
23 +from rich.markdown import Markdown
24 +from rich.text import Text
25
26
27 def get_status_code(e: Exception) -> Optional[int]:
@@ -66,6 +69,22 @@ def print_kitty(image_bytes: bytes):
69 logging.exception("Kitty rendering failed")
70
71
72 +def render_display_data(data: dict) -> Union[Markdown, Text, None]:
73 + """Extract the best text representation from a display_data dict.
74 +
75 + Priority: text/markdown > text/html (via html2text) > text/plain.
76 + Returns a Rich renderable (Markdown or Text) or None when no text mime
77 + type is present. Callers can pass the result directly to Console.print().
78 + """
79 + if "text/markdown" in data:
80 + return Markdown(data["text/markdown"])
81 + if "text/html" in data:
82 + return Markdown(html2text.html2text(data["text/html"]))
83 + if "text/plain" in data:
84 + return Text.from_ansi(data["text/plain"])
85 + return None
86 +
87 +
88 def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
89 image_bytes = base64.b64decode(image_b64)
90 # Print inline using Kitty protocol
tests/test_utils.py
+30 -1
@@ -15,7 +15,11 @@
15 import base64
16 from unittest.mock import MagicMock, patch
17
18 -from colab_cli.utils import handle_image, print_kitty
18 +import pytest
19 +from rich.markdown import Markdown
20 +from rich.text import Text
21 +
22 +from colab_cli.utils import handle_image, print_kitty, render_display_data
23
24
25 @patch("colab_cli.utils.sys.stdout.isatty", return_value=True)
@@ -56,3 +60,28 @@ def test_handle_image(mock_print_kitty, mock_tempfile, capsys):
60
61 captured = capsys.readouterr()
62 assert "/tmp/fake.png" in captured.out
63 +
64 +
65 +@pytest.mark.parametrize(
66 + "data, expected_markup",
67 + [
68 + ({"text/markdown": "**md**"}, "**md**"),
69 + ({"text/html": "<b>hi</b>"}, "**hi**\n\n"),
70 + ({"text/markdown": "**md**", "text/html": "<b>hi</b>"}, "**md**"),
71 + ({"text/html": "<b>hi</b>", "text/plain": "plain"}, "**hi**\n\n"),
72 + ],
73 +)
74 +def test_render_display_data_markdown(data, expected_markup):
75 + result = render_display_data(data)
76 + assert isinstance(result, Markdown)
77 + assert result.markup == expected_markup
78 +
79 +
80 +def test_render_display_data_plain():
81 + result = render_display_data({"text/plain": "plain"})
82 + assert isinstance(result, Text)
83 + assert result.plain == "plain"
84 +
85 +
86 +def test_render_display_data_none():
87 + assert render_display_data({"image/png": "..."}) is None
uv.lock
+11
@@ -319,6 +319,7 @@ dependencies = [
319 { name = "filelock" },
320 { name = "google-auth" },
321 { name = "google-auth-oauthlib" },
322 + { name = "html2text" },
323 { name = "jupyter-kernel-client" },
324 { name = "nbformat" },
325 { name = "packaging" },
@@ -346,6 +347,7 @@ requires-dist = [
347 { name = "filelock", specifier = ">=3.29.2" },
348 { name = "google-auth", specifier = ">=2.49.1" },
349 { name = "google-auth-oauthlib", specifier = ">=1.3.0" },
350 + { name = "html2text", specifier = ">=2024.2.26" },
351 { name = "jupyter-kernel-client", git = "https://github.com/googlecolab/jupyter-kernel-client.git" },
352 { name = "nbformat", specifier = ">=5.10.4" },
353 { name = "packaging", specifier = ">=24.0" },
@@ -367,6 +369,15 @@ dev = [
369 { name = "ruff", specifier = ">=0.15.6" },
370 ]
371
372 +[[package]]
373 +name = "html2text"
374 +version = "2025.4.15"
375 +source = { registry = "https://pypi.org/simple" }
376 +sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" }
377 +wheels = [
378 + { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" },
379 +]
380 +
381 [[package]]
382 name = "idna"
383 version = "3.11"