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.
- package/cli.js +148 -0
- package/core/app/__init__.py +0 -0
- package/core/app/colab_cli/__init__.py +0 -0
- package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
- package/core/app/colab_cli/auth.py +278 -0
- package/core/app/colab_cli/auto_update.py +248 -0
- package/core/app/colab_cli/cli.py +155 -0
- package/core/app/colab_cli/client.py +310 -0
- package/core/app/colab_cli/commands/__init__.py +14 -0
- package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/automation.py +265 -0
- package/core/app/colab_cli/commands/execution.py +362 -0
- package/core/app/colab_cli/commands/files.py +204 -0
- package/core/app/colab_cli/commands/run.py +477 -0
- package/core/app/colab_cli/commands/session.py +519 -0
- package/core/app/colab_cli/commands/utility.py +436 -0
- package/core/app/colab_cli/common.py +185 -0
- package/core/app/colab_cli/console.py +172 -0
- package/core/app/colab_cli/contents.py +93 -0
- package/core/app/colab_cli/converter.py +184 -0
- package/core/app/colab_cli/history.py +65 -0
- package/core/app/colab_cli/oauth_config.json +11 -0
- package/core/app/colab_cli/repl.py +173 -0
- package/core/app/colab_cli/runtime.py +262 -0
- package/core/app/colab_cli/state.py +156 -0
- package/core/app/colab_cli/utils.py +85 -0
- package/core/colab/worker.py +679 -0
- package/core/daemon.py +184 -0
- package/core/requirements.txt +8 -0
- 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}]")
|