ttsd-colabcli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/cli.js +148 -0
  2. package/core/app/__init__.py +0 -0
  3. package/core/app/colab_cli/__init__.py +0 -0
  4. package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  5. package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
  6. package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
  7. package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
  8. package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
  9. package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
  10. package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
  11. package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
  12. package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
  13. package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
  14. package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
  15. package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
  16. package/core/app/colab_cli/auth.py +278 -0
  17. package/core/app/colab_cli/auto_update.py +248 -0
  18. package/core/app/colab_cli/cli.py +155 -0
  19. package/core/app/colab_cli/client.py +310 -0
  20. package/core/app/colab_cli/commands/__init__.py +14 -0
  21. package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
  23. package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
  24. package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
  25. package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
  26. package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
  27. package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
  28. package/core/app/colab_cli/commands/automation.py +265 -0
  29. package/core/app/colab_cli/commands/execution.py +362 -0
  30. package/core/app/colab_cli/commands/files.py +204 -0
  31. package/core/app/colab_cli/commands/run.py +477 -0
  32. package/core/app/colab_cli/commands/session.py +519 -0
  33. package/core/app/colab_cli/commands/utility.py +436 -0
  34. package/core/app/colab_cli/common.py +185 -0
  35. package/core/app/colab_cli/console.py +172 -0
  36. package/core/app/colab_cli/contents.py +93 -0
  37. package/core/app/colab_cli/converter.py +184 -0
  38. package/core/app/colab_cli/history.py +65 -0
  39. package/core/app/colab_cli/oauth_config.json +11 -0
  40. package/core/app/colab_cli/repl.py +173 -0
  41. package/core/app/colab_cli/runtime.py +262 -0
  42. package/core/app/colab_cli/state.py +156 -0
  43. package/core/app/colab_cli/utils.py +85 -0
  44. package/core/colab/worker.py +679 -0
  45. package/core/daemon.py +184 -0
  46. package/core/requirements.txt +8 -0
  47. package/package.json +22 -0
@@ -0,0 +1,173 @@
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 datetime
16
+ from typing import Any, List, Optional
17
+
18
+ from prompt_toolkit import PromptSession
19
+ from prompt_toolkit.history import InMemoryHistory
20
+ from prompt_toolkit.key_binding import KeyBindings
21
+ from prompt_toolkit.lexers import PygmentsLexer
22
+ from prompt_toolkit.styles import Style
23
+ from pygments.lexers.python import PythonLexer
24
+ from rich.console import Console
25
+ from rich.text import Text
26
+
27
+ from app.colab_cli.runtime import ColabRuntime
28
+ from app.colab_cli.utils import handle_image
29
+
30
+ console = Console()
31
+
32
+
33
+ class ColabREPL:
34
+ def __init__(
35
+ self,
36
+ runtime: ColabRuntime,
37
+ session_name: Optional[str] = None,
38
+ history_logger: Optional[Any] = None,
39
+ output_image: Optional[str] = None,
40
+ ):
41
+ self.runtime = runtime
42
+ self.session_name = session_name
43
+ self.history_logger = history_logger
44
+ self.output_image = output_image
45
+ self.kb = KeyBindings()
46
+ self.console = console
47
+ self.repl_history: List[dict] = []
48
+
49
+ @self.kb.add("enter")
50
+ def _(event):
51
+ event.current_buffer.validate_and_handle()
52
+
53
+ @self.kb.add("escape", "enter")
54
+ @self.kb.add("c-j")
55
+ def _(event):
56
+ event.current_buffer.insert_text("\n")
57
+
58
+ self.session = PromptSession(
59
+ history=InMemoryHistory(),
60
+ lexer=PygmentsLexer(PythonLexer),
61
+ include_default_pygments_style=False,
62
+ key_bindings=self.kb,
63
+ multiline=True,
64
+ )
65
+ self.style = Style.from_dict(
66
+ {
67
+ "prompt": "bold blue",
68
+ "continuation": "#888888",
69
+ }
70
+ )
71
+
72
+ def print_info(self, message: str):
73
+ self.console.print(f"[bold blue][*][/bold blue] {message}")
74
+
75
+ def print_error(self, message: str):
76
+ self.console.print(f"[bold red][!][/bold red] {message}")
77
+
78
+ def display_output(self, output: dict):
79
+ if "text" in output:
80
+ self.console.print(Text.from_ansi(output["text"]), end="")
81
+ elif "data" in output:
82
+ data = output["data"]
83
+
84
+ # Check for images first
85
+ image_displayed = False
86
+ for mime_type in ["image/png", "image/jpeg"]:
87
+ if mime_type in data:
88
+ handle_image(
89
+ data[mime_type], mime_type, target_path=self.output_image
90
+ )
91
+ image_displayed = True
92
+ break
93
+
94
+ if "text/plain" in data:
95
+ text = data["text/plain"]
96
+ # 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))
102
+ elif output.get("output_type") == "error":
103
+ ename = output.get("ename", "Error")
104
+ evalue = output.get("evalue", "")
105
+ traceback = output.get("traceback", [])
106
+ if traceback:
107
+ self.console.print(Text.from_ansi("".join(traceback)))
108
+ else:
109
+ self.print_error(f"{ename}: {evalue}")
110
+
111
+ def execute(self, code: str):
112
+ if self.session_name:
113
+ from app.colab_cli.common import state
114
+
115
+ s = state.store.get(self.session_name)
116
+ if s:
117
+ s.last_execution = (
118
+ "REPL",
119
+ None,
120
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
121
+ )
122
+ state.store.add(s)
123
+
124
+ try:
125
+ outputs = self.runtime.execute_code(
126
+ code, output_hook=lambda o: self.display_output(o)
127
+ )
128
+ # Ensure next prompt starts on a newline after streaming
129
+ print()
130
+
131
+ self.repl_history.append({"input": code, "outputs": outputs or []})
132
+ if self.history_logger and self.session_name:
133
+ self.history_logger.log_event(
134
+ self.session_name,
135
+ "execution",
136
+ {"code": code, "outputs": outputs or []},
137
+ )
138
+ except Exception as e:
139
+ self.print_error(f"Execution failed: {e}")
140
+
141
+ def run(self):
142
+ self.console.print("Python 3 (Google Colab Runtime)\nType /quit to exit.")
143
+
144
+ while True:
145
+ try:
146
+ result = self.session.prompt(
147
+ ">>> ",
148
+ style=self.style,
149
+ )
150
+
151
+ if result is None:
152
+ continue
153
+
154
+ code = result.strip()
155
+
156
+ if not code:
157
+ continue
158
+
159
+ if code.lower() in ("/quit", "quit()", "exit()"):
160
+ break
161
+
162
+ self.execute(code)
163
+
164
+ except EOFError:
165
+ break
166
+ except KeyboardInterrupt:
167
+ print()
168
+ continue
169
+ except Exception as e:
170
+ self.print_error(f"REPL Error: {e}")
171
+
172
+ self.print_info("Goodbye!")
173
+ self.runtime.stop()
@@ -0,0 +1,262 @@
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 logging
16
+ import time
17
+ from typing import Any, Callable, Dict, List, Optional
18
+
19
+ import jupyter_kernel_client
20
+ import requests
21
+
22
+
23
+ class ColabRuntime:
24
+ def __init__(
25
+ self,
26
+ url: str,
27
+ token: str,
28
+ session_name: Optional[str] = None,
29
+ history: Optional[Any] = None,
30
+ kernel_id: Optional[str] = None,
31
+ session_id: Optional[str] = None,
32
+ on_kernel_started: Optional[Callable[[str], None]] = None,
33
+ on_session_started: Optional[Callable[[str], None]] = None,
34
+ ):
35
+ self.url = url
36
+ self.token = token
37
+ self.session_name = session_name
38
+ self.history = history
39
+ self.kernel_id = kernel_id
40
+ self.session_id = session_id
41
+ self.on_kernel_started = on_kernel_started
42
+ self.on_session_started = on_session_started
43
+ self._kernel_client = None
44
+ self.colab_request_hook: Optional[Callable[[Dict[str, Any], Any], None]] = None
45
+
46
+ def _apply_ws_hook(self):
47
+ wsclient = self._kernel_client._manager.client
48
+ original_on_message = wsclient.kernel_socket.on_message
49
+
50
+ def hooked_on_message(s_ws, message):
51
+ if not self.colab_request_hook:
52
+ return original_on_message(s_ws, message)
53
+
54
+ try:
55
+ from jupyter_kernel_client.wsclient import JupyterSubprotocol
56
+
57
+ if wsclient._subprotocol == JupyterSubprotocol.DEFAULT:
58
+ from jupyter_kernel_client.wsclient import (
59
+ deserialize_msg_from_ws_default,
60
+ )
61
+
62
+ deserialize_msg = deserialize_msg_from_ws_default(message)
63
+ elif wsclient._subprotocol == JupyterSubprotocol.V1:
64
+ from jupyter_kernel_client.wsclient import (
65
+ deserialize_msg_from_ws_v1,
66
+ )
67
+
68
+ channel, msg_list = deserialize_msg_from_ws_v1(message)
69
+ deserialize_msg = wsclient.session.deserialize(msg_list)
70
+ else:
71
+ deserialize_msg = None
72
+
73
+ if deserialize_msg:
74
+ msg_type = deserialize_msg.get("msg_type")
75
+ if msg_type == "colab_request":
76
+ # We pass the deserialized msg and the wsclient to the hook
77
+ if self.colab_request_hook(deserialize_msg, wsclient):
78
+ # If the hook returns True, we intercept and do NOT pass to original
79
+ return
80
+
81
+ except Exception as e:
82
+ logging.debug(f"Error in colab_request hook: {e}")
83
+
84
+ # Call original for all other messages
85
+ original_on_message(s_ws, message)
86
+
87
+ wsclient.kernel_socket.on_message = hooked_on_message
88
+
89
+ @property
90
+ def kernel_client(self):
91
+ if not self._kernel_client:
92
+ retries = 3
93
+ backoff = 2
94
+ last_err = None
95
+
96
+ for i in range(retries):
97
+ try:
98
+ client_kwargs = {
99
+ "subprotocol": jupyter_kernel_client.JupyterSubprotocol.DEFAULT,
100
+ "extra_params": {"colab-runtime-proxy-token": self.token},
101
+ }
102
+ if self.session_id:
103
+ # WSSession (Session) expects 'session' for the ID
104
+ client_kwargs["session"] = self.session_id
105
+
106
+ self._kernel_client = jupyter_kernel_client.KernelClient(
107
+ server_url=self.url,
108
+ token=self.token,
109
+ kernel_id=self.kernel_id,
110
+ client_kwargs=client_kwargs,
111
+ headers={
112
+ "X-Colab-Client-Agent": "colab-cli",
113
+ "X-Colab-Runtime-Proxy-Token": self.token,
114
+ },
115
+ )
116
+ # Force _own_kernel to False. This prevents jupyter-kernel-client
117
+ # from automatically deleting the kernel when the client is closed or deleted.
118
+ self._kernel_client._own_kernel = False
119
+
120
+ self._kernel_client.start()
121
+ self._apply_ws_hook()
122
+
123
+ # Capture IDs if we started fresh
124
+ if not self.kernel_id and self._kernel_client.id:
125
+ self.kernel_id = self._kernel_client.id
126
+ if self.on_kernel_started:
127
+ self.on_kernel_started(self.kernel_id)
128
+
129
+ if (
130
+ not self.session_id
131
+ and self._kernel_client._manager.client.session.session
132
+ ):
133
+ self.session_id = (
134
+ self._kernel_client._manager.client.session.session
135
+ )
136
+ if self.on_session_started:
137
+ self.on_session_started(self.session_id)
138
+ break
139
+ except (
140
+ requests.exceptions.ReadTimeout,
141
+ requests.exceptions.ConnectTimeout,
142
+ ) as e:
143
+ last_err = e
144
+ if i < retries - 1:
145
+ sleep_time = backoff ** (i + 1)
146
+ logging.debug(
147
+ f"Kernel startup timeout, retrying in {sleep_time}s..."
148
+ f" ({i + 1}/{retries})"
149
+ )
150
+ time.sleep(sleep_time)
151
+ else:
152
+ raise last_err
153
+ except Exception as e:
154
+ raise e
155
+
156
+ return self._kernel_client
157
+
158
+ def restart(
159
+ self,
160
+ timeout: Optional[float] = None,
161
+ ):
162
+ self.kernel_client.restart(timeout=timeout)
163
+
164
+ def execute_code(
165
+ self,
166
+ code: str,
167
+ allow_stdin: bool = False,
168
+ stdin_hook: Any = None,
169
+ output_hook: Optional[Callable[[Dict[str, Any]], None]] = None,
170
+ timeout: Optional[float] = None,
171
+ ) -> List[Dict[str, Any]]:
172
+ # ``jupyter_kernel_client`` defaults ``timeout`` to ``REQUEST_TIMEOUT``
173
+ # (10 seconds) on both ``execute`` and ``execute_interactive``. That
174
+ # value is a wall-clock budget that shrinks every time the poll loop
175
+ # iterates -- as long as iopub/stdin events arrive back-to-back the
176
+ # call survives, but a single >10s quiet stretch (e.g. a kernel
177
+ # blocked on ``input_request`` while the user OAuths in the browser)
178
+ # will raise ``TimeoutError`` even though the underlying execution is
179
+ # still healthy. Callers that know they need a longer ceiling can
180
+ # pass ``timeout=`` here; otherwise we forward whatever the upstream
181
+ # default is (currently 10s).
182
+ kwargs = {"allow_stdin": allow_stdin}
183
+ if timeout is not None:
184
+ kwargs["timeout"] = timeout
185
+
186
+ # Wrap stdin_hook to log inputs
187
+ original_stdin_hook = stdin_hook
188
+
189
+ def wrapped_stdin_hook(prompt):
190
+ if self.history and self.session_name:
191
+ self.history.log_event(
192
+ self.session_name, "stdin_request", {"prompt": prompt}
193
+ )
194
+
195
+ res = original_stdin_hook(prompt) if original_stdin_hook else input(prompt)
196
+
197
+ if self.history and self.session_name:
198
+ self.history.log_event(self.session_name, "input_reply", {"value": res})
199
+ return res
200
+
201
+ if allow_stdin:
202
+ kwargs["stdin_hook"] = wrapped_stdin_hook
203
+
204
+ if output_hook:
205
+ # If we have an output hook, we use execute_interactive and manage buffering ourselves
206
+ outputs = []
207
+
208
+ def wrapped_output_hook(msg):
209
+ from jupyter_kernel_client.client import (
210
+ output_hook as default_output_hook,
211
+ )
212
+
213
+ # Update local outputs list using the default logic
214
+ new_indexes = default_output_hook(outputs, msg)
215
+ # If new outputs were added, call our streaming hook with the new data
216
+ if new_indexes:
217
+ for idx in sorted(new_indexes):
218
+ if idx < len(outputs):
219
+ output_hook(outputs[idx])
220
+
221
+ reply = self.kernel_client.execute_interactive(
222
+ code, output_hook=wrapped_output_hook, **kwargs
223
+ )
224
+ # execute_interactive returns the raw reply message
225
+ reply_content = reply["content"] if reply else {"status": "error"}
226
+ else:
227
+ reply = self.kernel_client.execute(code, **kwargs)
228
+ if not reply:
229
+ return []
230
+ outputs = reply.get("outputs", [])
231
+ reply_content = reply
232
+
233
+ # If there's an error status but no error in outputs, synthesize one
234
+ if reply_content.get("status") == "error":
235
+ has_error_output = any(o.get("output_type") == "error" for o in outputs)
236
+ if not has_error_output:
237
+ outputs.append(
238
+ {
239
+ "output_type": "error",
240
+ "ename": reply_content.get("ename", "Error"),
241
+ "evalue": reply_content.get("evalue", "Unknown error"),
242
+ "traceback": reply_content.get("traceback", []),
243
+ }
244
+ )
245
+
246
+ return outputs
247
+
248
+ def stop(self, shutdown_kernel: bool = False):
249
+ if self._kernel_client:
250
+ try:
251
+ # We manage kernel lifecycle explicitly.
252
+ # To prevent automatic shutdown, we bypass the manager's stop() and
253
+ # directly close the channels and socket.
254
+ client = self._kernel_client._manager.client
255
+ client.stop_channels()
256
+ if client.kernel_socket:
257
+ client.kernel_socket.close()
258
+
259
+ if shutdown_kernel:
260
+ self._kernel_client._manager.shutdown_kernel(now=True)
261
+ except Exception:
262
+ logging.exception("Error stopping kernel client")
@@ -0,0 +1,156 @@
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 contextlib
16
+ import json
17
+ import os
18
+ from datetime import datetime
19
+ from typing import Dict, Optional, Tuple, Iterator, IO
20
+
21
+ import filelock
22
+ from pydantic import BaseModel
23
+
24
+
25
+ class SessionState(BaseModel):
26
+ name: str
27
+ token: str
28
+ url: str
29
+ endpoint: str
30
+ variant: str = "DEFAULT"
31
+ accelerator: str = "NONE"
32
+ kernel_id: Optional[str] = None
33
+ session_id: Optional[str] = None
34
+ last_execution: Optional[Tuple[str, Optional[str], str]] = None
35
+ running: Optional[str] = None
36
+ keep_alive_pid: Optional[int] = None
37
+
38
+
39
+ class Settings(BaseModel):
40
+ update_url: str = "https://pypi.org/pypi/google-colab-cli/json"
41
+ last_check: Optional[datetime] = None
42
+ enable_update_check: bool = True
43
+ # Highest version seen on the update source; cached for the banner.
44
+ latest_version: Optional[str] = None
45
+
46
+
47
+ class _LockedFileStore:
48
+ def __init__(self, path: str):
49
+ self.path = path
50
+ self.lock_path = "%s.lock" % self.path
51
+ # ReadWriteLock gives us shared (concurrent) readers and exclusive
52
+ # writers -- the cross-platform equivalent of fcntl LOCK_SH/LOCK_EX.
53
+ # is_singleton=False keeps each store's lock independent: with the
54
+ # default (True), two StateStore instances for the same path in one
55
+ # process are merged into a single reentrant lock, whose reentrancy
56
+ # guard then raises RuntimeError when two threads contend for the write
57
+ # lock. We want them to actually serialize via the underlying file lock.
58
+ self._rwlock = filelock.ReadWriteLock(self.lock_path, is_singleton=False)
59
+ self._ensure_dir()
60
+
61
+ def _ensure_dir(self):
62
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
63
+
64
+ def _write_data(self, f: IO, data: str):
65
+ f.seek(0)
66
+ f.truncate()
67
+ f.write(data)
68
+ f.flush()
69
+ os.fsync(f.fileno())
70
+
71
+ @contextlib.contextmanager
72
+ def _lock_shared(self) -> Iterator[Optional[IO]]:
73
+ if not os.path.exists(self.path):
74
+ yield None
75
+ return
76
+ with self._rwlock.read_lock():
77
+ with open(self.path, "r") as f:
78
+ yield f
79
+
80
+ @contextlib.contextmanager
81
+ def _lock_exclusive(self) -> Iterator[IO]:
82
+ with self._rwlock.write_lock():
83
+ with open(self.path, "a+") as f:
84
+ yield f
85
+
86
+
87
+ class SettingsStore(_LockedFileStore):
88
+ def __init__(self, path: Optional[str] = None):
89
+ if not path:
90
+ path = os.path.expanduser("~/.config/colab-cli/settings.json")
91
+ super().__init__(path)
92
+
93
+ def load(self) -> Settings:
94
+ with self._lock_shared() as f:
95
+ if f is None:
96
+ return Settings()
97
+ try:
98
+ content = f.read()
99
+ if not content or content.isspace():
100
+ return Settings()
101
+ data = json.loads(content)
102
+ return Settings.model_validate(data)
103
+ except Exception:
104
+ return Settings()
105
+
106
+ def save(self, settings: Settings):
107
+ with self._lock_exclusive() as f:
108
+ self._write_data(f, settings.model_dump_json(indent=2))
109
+
110
+
111
+ class StateStore(_LockedFileStore):
112
+ def __init__(self, path: Optional[str] = None):
113
+ if not path:
114
+ path = os.path.expanduser("~/.config/colab-cli/sessions.json")
115
+ super().__init__(path)
116
+
117
+ def _load_raw(self, f) -> Dict[str, SessionState]:
118
+ try:
119
+ f.seek(0)
120
+ content = f.read()
121
+ if not content or content.isspace():
122
+ return {}
123
+ data = json.loads(content)
124
+ return {k: SessionState(**v) for k, v in data.items()}
125
+ except Exception:
126
+ return {}
127
+
128
+ def _save_raw(self, f, sessions: Dict[str, SessionState]):
129
+ content = json.dumps({k: v.model_dump() for k, v in sessions.items()}, indent=2)
130
+ self._write_data(f, content)
131
+
132
+ def add(self, state: SessionState):
133
+ with self._lock_exclusive() as f:
134
+ sessions = self._load_raw(f)
135
+ sessions[state.name] = state
136
+ self._save_raw(f, sessions)
137
+
138
+ def get(self, name: str) -> Optional[SessionState]:
139
+ with self._lock_shared() as f:
140
+ if f is None:
141
+ return None
142
+ sessions = self._load_raw(f)
143
+ return sessions.get(name)
144
+
145
+ def remove(self, name: str):
146
+ with self._lock_exclusive() as f:
147
+ sessions = self._load_raw(f)
148
+ if name in sessions:
149
+ del sessions[name]
150
+ self._save_raw(f, sessions)
151
+
152
+ def list(self) -> Dict[str, SessionState]:
153
+ with self._lock_shared() as f:
154
+ if f is None:
155
+ return {}
156
+ return self._load_raw(f)
@@ -0,0 +1,85 @@
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 logging
17
+ import sys
18
+ import tempfile
19
+
20
+
21
+ from typing import Optional
22
+
23
+
24
+ def get_status_code(e: Exception) -> Optional[int]:
25
+ """Safely extracts status code from various exception types."""
26
+ if hasattr(e, "response") and e.response is not None:
27
+ if hasattr(e.response, "status_code"):
28
+ return e.response.status_code
29
+ if hasattr(e, "status_code"):
30
+ return e.status_code
31
+ return None
32
+
33
+
34
+ def is_terminal_error(e: Exception) -> bool:
35
+ """Checks if an exception indicates a lost session (404/401)."""
36
+ code = get_status_code(e)
37
+ if code in (404, 401):
38
+ return True
39
+ # Some exceptions from jupyter-kernel-client might wrap the real one or be different
40
+ err_msg = str(e)
41
+ if "404" in err_msg or "401" in err_msg:
42
+ return True
43
+ return False
44
+
45
+
46
+ def print_kitty(image_bytes: bytes):
47
+ """
48
+ Outputs an image using the Kitty Graphics Protocol.
49
+ Expects PNG bytes.
50
+
51
+ No-op when stdout is not a TTY: the escape sequence is meaningless to a
52
+ file/pipe and visually corrupts captured output (e.g. when piping
53
+ `colab exec` into a shell tool, redirecting to a log file, or running
54
+ under non-Kitty terminals). Callers still get the image via
55
+ `handle_image`'s file-write path.
56
+ """
57
+ if not sys.stdout.isatty():
58
+ return
59
+ try:
60
+ b64_data = base64.b64encode(image_bytes).decode("ascii")
61
+ sys.stdout.write("\n\033_Ga=T,f=100;")
62
+ sys.stdout.write(b64_data)
63
+ sys.stdout.write("\033\\\n")
64
+ sys.stdout.flush()
65
+ except Exception:
66
+ logging.exception("Kitty rendering failed")
67
+
68
+
69
+ def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
70
+ image_bytes = base64.b64decode(image_b64)
71
+ # Print inline using Kitty protocol
72
+ print_kitty(image_bytes)
73
+
74
+ if target_path:
75
+ # If a target path is specified, save it there
76
+ with open(target_path, "wb") as f:
77
+ f.write(image_bytes)
78
+ print(f"\n[Image saved to: {target_path}]")
79
+ else:
80
+ # Save to temp file as fallback
81
+ ext = mime_type.split("/")[-1]
82
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
83
+ tmp.write(image_bytes)
84
+ tmp.close()
85
+ print(f"\n[Image saved to: {tmp.name}]")