Skip to content

Commit c58a851

Browse files
authored
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.
1 parent 05027b6 commit c58a851

7 files changed

Lines changed: 89 additions & 18 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"filelock>=3.29.2",
2828
"google-auth>=2.49.1",
2929
"google-auth-oauthlib>=1.3.0",
30+
"html2text>=2024.2.26",
3031
"jupyter-kernel-client",
3132
"nbformat>=5.10.4",
3233
"packaging>=24.0",

src/colab_cli/commands/automation.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@
1818
import json
1919
from typing import Optional, List
2020
import typer
21+
from rich.console import Console
2122
from typing_extensions import Annotated
2223

2324
from colab_cli.runtime import ColabRuntime
2425
from colab_cli.contents import ContentsClient
2526
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()
2730

2831

2932
# Default execute() timeout for human-in-the-loop automations (auth /
@@ -35,6 +38,7 @@
3538
INTERACTIVE_AUTOMATION_TIMEOUT_SEC = 600
3639

3740

41+
3842
def run_automation(
3943
name: str,
4044
op: str,
@@ -153,8 +157,9 @@ def drivefs_hook(deserialize_msg, wsclient):
153157
if "text" in out:
154158
sys.stdout.write(out["text"])
155159
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)
158163
elif out.get("output_type") == "error":
159164
ename = out.get("ename", "Error")
160165
evalue = out.get("evalue", "")

src/colab_cli/commands/execution.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@
2020
import typer
2121
import uuid
2222
from nbformat.v4 import new_output
23+
from rich.console import Console
2324
from typing import Optional
2425
from typing_extensions import Annotated
2526

2627
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
2829
from colab_cli.console import connect_console
2930

31+
_console = Console()
32+
3033
TITLE_REGEX = re.compile(r"^\s*#\s*@title\s+(.*)", re.MULTILINE)
3134

3235

@@ -72,15 +75,17 @@ def save_output(outputs, cell):
7275
)
7376

7477

78+
7579
def display_output(out, output_image=None):
7680
if out.get("output_type") == "stream":
7781
stream = sys.stderr if out.get("name") == "stderr" else sys.stdout
7882
stream.write(out.get("text", ""))
7983
stream.flush()
8084
elif "data" in out:
8185
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)
8489
if png := data.get("image/png"):
8590
handle_image(png, "image/png", target_path=output_image)
8691
elif jpeg := data.get("image/jpeg"):

src/colab_cli/repl.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@
2525
from rich.text import Text
2626

2727
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
2929

30-
console = Console()
3130

3231

3332
class ColabREPL:
@@ -43,7 +42,7 @@ def __init__(
4342
self.history_logger = history_logger
4443
self.output_image = output_image
4544
self.kb = KeyBindings()
46-
self.console = console
45+
self.console = Console()
4746
self.repl_history: List[dict] = []
4847

4948
@self.kb.add("enter")
@@ -91,14 +90,16 @@ def display_output(self, output: dict):
9190
image_displayed = True
9291
break
9392

94-
if "text/plain" in data:
95-
text = data["text/plain"]
93+
text = render_display_data(data)
94+
if text is not None:
9695
# 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)
102103
elif output.get("output_type") == "error":
103104
ename = output.get("ename", "Error")
104105
evalue = output.get("evalue", "")

src/colab_cli/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
# limitations under the License.
1414

1515
import base64
16+
import html2text
1617
import logging
1718
import sys
1819
import tempfile
1920

21+
from typing import Optional, Union
2022

21-
from typing import Optional
23+
from rich.markdown import Markdown
24+
from rich.text import Text
2225

2326

2427
def get_status_code(e: Exception) -> Optional[int]:
@@ -66,6 +69,22 @@ def print_kitty(image_bytes: bytes):
6669
logging.exception("Kitty rendering failed")
6770

6871

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+
6988
def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
7089
image_bytes = base64.b64decode(image_b64)
7190
# Print inline using Kitty protocol

tests/test_utils.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
import base64
1616
from unittest.mock import MagicMock, patch
1717

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
1923

2024

2125
@patch("colab_cli.utils.sys.stdout.isatty", return_value=True)
@@ -56,3 +60,28 @@ def test_handle_image(mock_print_kitty, mock_tempfile, capsys):
5660

5761
captured = capsys.readouterr()
5862
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

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)