main
py 104 lines 3.51 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
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
23 from rich.markdown import Markdown
24 from rich.text import Text
25
26
27 def get_status_code(e: Exception) -> Optional[int]:
28 """Safely extracts status code from various exception types."""
29 if hasattr(e, "response") and e.response is not None:
30 if hasattr(e.response, "status_code"):
31 return e.response.status_code
32 if hasattr(e, "status_code"):
33 return e.status_code
34 return None
35
36
37 def is_terminal_error(e: Exception) -> bool:
38 """Checks if an exception indicates a lost session (404/401)."""
39 code = get_status_code(e)
40 if code in (404, 401):
41 return True
42 # Some exceptions from jupyter-kernel-client might wrap the real one or be different
43 err_msg = str(e)
44 if "404" in err_msg or "401" in err_msg:
45 return True
46 return False
47
48
49 def print_kitty(image_bytes: bytes):
50 """
51 Outputs an image using the Kitty Graphics Protocol.
52 Expects PNG bytes.
53
54 No-op when stdout is not a TTY: the escape sequence is meaningless to a
55 file/pipe and visually corrupts captured output (e.g. when piping
56 `colab exec` into a shell tool, redirecting to a log file, or running
57 under non-Kitty terminals). Callers still get the image via
58 `handle_image`'s file-write path.
59 """
60 if not sys.stdout.isatty():
61 return
62 try:
63 b64_data = base64.b64encode(image_bytes).decode("ascii")
64 sys.stdout.write("\n\033_Ga=T,f=100;")
65 sys.stdout.write(b64_data)
66 sys.stdout.write("\033\\\n")
67 sys.stdout.flush()
68 except Exception:
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
91 print_kitty(image_bytes)
92
93 if target_path:
94 # If a target path is specified, save it there
95 with open(target_path, "wb") as f:
96 f.write(image_bytes)
97 print(f"\n[Image saved to: {target_path}]")
98 else:
99 # Save to temp file as fallback
100 ext = mime_type.split("/")[-1]
101 tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
102 tmp.write(image_bytes)
103 tmp.close()
104 print(f"\n[Image saved to: {tmp.name}]")