google-colab-cli 0.5.4__py3-none-any.whl

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.
@@ -0,0 +1,356 @@
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
+ import nbformat
17
+ import os
18
+ import re
19
+ import sys
20
+ import typer
21
+ import uuid
22
+ from nbformat.v4 import new_output
23
+ from typing import Optional
24
+ from typing_extensions import Annotated
25
+
26
+ from colab_cli.runtime import ColabRuntime
27
+ from colab_cli.utils import handle_image, is_terminal_error
28
+ from colab_cli.console import connect_console
29
+
30
+ TITLE_REGEX = re.compile(r"^\s*#\s*@title\s+(.*)", re.MULTILINE)
31
+
32
+
33
+ def is_stdin_tty():
34
+ return sys.stdin.isatty()
35
+
36
+
37
+ def save_output(outputs, cell):
38
+ if cell is None:
39
+ return
40
+
41
+ if not hasattr(cell, "outputs"):
42
+ cell.outputs = []
43
+ else:
44
+ cell.outputs.clear()
45
+
46
+ for out in outputs:
47
+ if out.get("output_type") == "stream":
48
+ cell.outputs.append(
49
+ new_output(
50
+ output_type="stream",
51
+ name=out.get("name", "stdout"),
52
+ text=out.get("text", ""),
53
+ )
54
+ )
55
+ elif "data" in out:
56
+ output_type = out.get("output_type", "display_data")
57
+ cell.outputs.append(
58
+ new_output(
59
+ output_type=output_type,
60
+ data=out["data"],
61
+ metadata=out.get("metadata", {}),
62
+ )
63
+ )
64
+ elif out.get("output_type") == "error":
65
+ cell.outputs.append(
66
+ new_output(
67
+ output_type="error",
68
+ ename=out.get("ename", "Error"),
69
+ evalue=out.get("evalue", ""),
70
+ traceback=out.get("traceback", []),
71
+ )
72
+ )
73
+
74
+
75
+ def display_output(out, output_image=None):
76
+ if out.get("output_type") == "stream":
77
+ stream = sys.stderr if out.get("name") == "stderr" else sys.stdout
78
+ stream.write(out.get("text", ""))
79
+ stream.flush()
80
+ elif "data" in out:
81
+ data = out["data"]
82
+ if text := data.get("text/plain"):
83
+ typer.echo(text)
84
+ if png := data.get("image/png"):
85
+ handle_image(png, "image/png", target_path=output_image)
86
+ elif jpeg := data.get("image/jpeg"):
87
+ handle_image(jpeg, "image/jpeg", target_path=output_image)
88
+ elif out.get("output_type") == "error":
89
+ tb = out.get("traceback", [])
90
+ if tb:
91
+ sys.stderr.write("".join(tb) + "\n")
92
+ else:
93
+ ename = out.get("ename", "Error")
94
+ evalue = out.get("evalue", "")
95
+ sys.stderr.write(f"{ename}: {evalue}\n")
96
+ else:
97
+ # Ignore silent outputs like metadata or clear_output for streaming
98
+ pass
99
+
100
+
101
+ def exec_command(
102
+ session: Annotated[
103
+ Optional[str], typer.Option("-s", "--session", help="Session name")
104
+ ] = None,
105
+ file: Annotated[
106
+ Optional[str], typer.Option("-f", "--file", help="File to execute")
107
+ ] = None,
108
+ output_image: Annotated[
109
+ Optional[str], typer.Option("--output-image", help="Path to save plot")
110
+ ] = None,
111
+ ):
112
+ """Execute code in a session"""
113
+ from colab_cli.common import state
114
+
115
+ name = state.resolve_session(session)
116
+ s = state.store.get(name)
117
+ if not s:
118
+ typer.echo(f"[colab] Session '{name}' not found.")
119
+ raise typer.Exit(1)
120
+
121
+ code_blocks = []
122
+ if file:
123
+ if file.endswith(".ipynb"):
124
+ typer.echo(f"[colab] Parsing notebook '{file}'...")
125
+ with open(file, "r", encoding="utf-8") as f:
126
+ nb = nbformat.read(f, as_version=4)
127
+ for cell in nb.cells:
128
+ # nbformat v4.5+ requires 'id' at the top level
129
+ if not hasattr(cell, "id") or not cell.id:
130
+ cell.id = str(uuid.uuid4())
131
+
132
+ if cell.cell_type == "code":
133
+ code_blocks.append(
134
+ {"code": cell.source, "id": cell.id, "cell": cell}
135
+ )
136
+ else:
137
+ with open(file, "r") as f:
138
+ code_blocks.append({"code": f.read(), "id": None})
139
+ else:
140
+ if is_stdin_tty():
141
+ typer.echo("[colab] Error: No input provided. Pipe code or provide a file.")
142
+ raise typer.Exit(1)
143
+ code_blocks.append({"code": sys.stdin.read(), "id": None})
144
+
145
+ if not any(b["code"].strip() for b in code_blocks):
146
+ raise typer.Exit(0)
147
+
148
+ def on_started(kid):
149
+ s.kernel_id = kid
150
+ state.store.add(s)
151
+
152
+ def on_sess_started(sid):
153
+ s.session_id = sid
154
+ state.store.add(s)
155
+
156
+ runtime = ColabRuntime(
157
+ s.url,
158
+ s.token,
159
+ kernel_id=s.kernel_id,
160
+ session_id=s.session_id,
161
+ on_kernel_started=on_started,
162
+ on_session_started=on_sess_started,
163
+ )
164
+ try:
165
+ # Ensure we are in /content which is the standard Colab working directory
166
+ runtime.execute_code(
167
+ "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
168
+ )
169
+ except Exception as e:
170
+ if is_terminal_error(e):
171
+ typer.echo(
172
+ f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
173
+ )
174
+ state.prune_session(name)
175
+ raise typer.Exit(1)
176
+ raise e
177
+
178
+ try:
179
+ is_nb = file and file.endswith(".ipynb")
180
+ s.running = f"exec({file or 'stdin'})"
181
+ state.store.add(s)
182
+
183
+ for i, block in enumerate(code_blocks):
184
+ code = block["code"]
185
+ identifier = None
186
+ if is_nb:
187
+ title_match = TITLE_REGEX.search(code)
188
+ if title_match:
189
+ identifier = title_match.group(1).strip()
190
+ elif block.get("id"):
191
+ identifier = block["id"]
192
+ else:
193
+ identifier = ""
194
+
195
+ identifier_str = f" - {identifier}" if identifier else ""
196
+ typer.echo(
197
+ f"[colab] Executing cell {i + 1}/{len(code_blocks)}{identifier_str}..."
198
+ )
199
+
200
+ s.last_execution = (
201
+ file or "stdin",
202
+ identifier,
203
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
204
+ )
205
+ state.store.add(s)
206
+
207
+ outputs = runtime.execute_code(
208
+ code, output_hook=lambda o: display_output(o, output_image)
209
+ )
210
+ if "cell" in block:
211
+ save_output(outputs, block["cell"])
212
+ state.history.log_event(
213
+ name,
214
+ "execution",
215
+ {
216
+ "code": code,
217
+ "outputs": outputs,
218
+ "cell_index": i if len(code_blocks) > 1 else None,
219
+ "cell_id": block.get("id"),
220
+ },
221
+ )
222
+ finally:
223
+ s.running = None
224
+ state.store.add(s)
225
+ runtime.stop()
226
+ if file and file.endswith(".ipynb"):
227
+ output_file = os.path.splitext(file)[0] + "_output.ipynb"
228
+ typer.echo(f"[colab] Saving notebook with outputs to '{output_file}'...")
229
+ with open(output_file, "w", encoding="utf-8") as f:
230
+ nbformat.write(nb, f)
231
+
232
+
233
+ def repl(
234
+ session: Annotated[
235
+ Optional[str], typer.Option("-s", "--session", help="Session name")
236
+ ] = None,
237
+ output_image: Annotated[
238
+ Optional[str], typer.Option("--output-image", help="Path to save plot")
239
+ ] = None,
240
+ ):
241
+ """Start an interactive REPL"""
242
+ from colab_cli.common import state
243
+
244
+ name = state.resolve_session(session)
245
+ s = state.store.get(name)
246
+ if not s:
247
+ typer.echo(f"[colab] Session '{name}' not found.")
248
+ raise typer.Exit(1)
249
+
250
+ def on_started(kid):
251
+ s.kernel_id = kid
252
+ state.store.add(s)
253
+
254
+ def on_sess_started(sid):
255
+ s.session_id = sid
256
+ state.store.add(s)
257
+
258
+ runtime = ColabRuntime(
259
+ s.url,
260
+ s.token,
261
+ kernel_id=s.kernel_id,
262
+ session_id=s.session_id,
263
+ on_kernel_started=on_started,
264
+ on_session_started=on_sess_started,
265
+ )
266
+ try:
267
+ # Ensure we are in /content which is the standard Colab working directory
268
+ runtime.execute_code(
269
+ "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
270
+ )
271
+ except Exception as e:
272
+ if is_terminal_error(e):
273
+ typer.echo(
274
+ f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
275
+ )
276
+ state.prune_session(name)
277
+ raise typer.Exit(1)
278
+ raise e
279
+
280
+ if not is_stdin_tty():
281
+ code = sys.stdin.read()
282
+ if not code.strip():
283
+ raise typer.Exit(0)
284
+
285
+ s.last_execution = (
286
+ "stdin",
287
+ None,
288
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
289
+ )
290
+ s.running = "repl(stdin)"
291
+ state.store.add(s)
292
+ try:
293
+ outputs = runtime.execute_code(
294
+ code, output_hook=lambda o: display_output(o, output_image)
295
+ )
296
+ state.history.log_event(
297
+ name, "execution", {"code": code, "outputs": outputs, "source": "piped"}
298
+ )
299
+ finally:
300
+ s.running = None
301
+ state.store.add(s)
302
+ runtime.stop()
303
+ else:
304
+ from colab_cli.repl import ColabREPL
305
+
306
+ s.running = "repl"
307
+ state.store.add(s)
308
+ try:
309
+ repl_inst = ColabREPL(
310
+ runtime,
311
+ session_name=s.name,
312
+ history_logger=state.history,
313
+ output_image=output_image,
314
+ )
315
+ state.history.log_event(name, "repl_started", {})
316
+ repl_inst.run()
317
+ finally:
318
+ s.running = None
319
+ state.store.add(s)
320
+
321
+
322
+ def console(
323
+ session: Annotated[
324
+ Optional[str], typer.Option("-s", "--session", help="Session name")
325
+ ] = None,
326
+ ):
327
+ """Connect to raw TTY console"""
328
+ from colab_cli.common import state
329
+
330
+ name = state.resolve_session(session)
331
+ s = state.store.get(name)
332
+ if not s:
333
+ typer.echo(f"[colab] Session '{name}' not found.")
334
+ raise typer.Exit(1)
335
+ state.history.log_event(s.name, "console_started", {})
336
+ s.running = "console"
337
+ state.store.add(s)
338
+ try:
339
+ connect_console(s)
340
+ except Exception as e:
341
+ if is_terminal_error(e):
342
+ typer.echo(
343
+ f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
344
+ )
345
+ state.prune_session(name)
346
+ raise typer.Exit(1)
347
+ raise e
348
+ finally:
349
+ s.running = None
350
+ state.store.add(s)
351
+
352
+
353
+ def register(app: typer.Typer):
354
+ app.command(name="exec")(exec_command)
355
+ app.command()(repl)
356
+ app.command()(console)
@@ -0,0 +1,204 @@
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 click
16
+ import hashlib
17
+ import os
18
+ import tempfile
19
+ import typer
20
+ from typing import Optional
21
+ from typing_extensions import Annotated
22
+
23
+ from colab_cli.contents import ContentsClient
24
+
25
+
26
+ def ls(
27
+ session: Annotated[
28
+ Optional[str], typer.Option("-s", "--session", help="Session name")
29
+ ] = None,
30
+ path: Annotated[str, typer.Argument(help="Remote path to list")] = "content",
31
+ ):
32
+ """List files in a session"""
33
+ from colab_cli.common import state
34
+
35
+ name = state.resolve_session(session)
36
+ s = state.store.get(name)
37
+ if not s:
38
+ typer.echo(f"[colab] Session '{name}' not found.")
39
+ raise typer.Exit(1)
40
+ contents = ContentsClient(s)
41
+ try:
42
+ data = contents.list_dir(path)
43
+ state.history.log_event(name, "file_operation", {"op": "ls", "path": path})
44
+ if data.get("type") == "directory":
45
+ items = data.get("content", [])
46
+ for item in sorted(
47
+ items, key=lambda x: (x.get("type") != "directory", x.get("name"))
48
+ ):
49
+ suffix = "/" if item.get("type") == "directory" else ""
50
+ typer.echo(f"{item.get('name')}{suffix}")
51
+ else:
52
+ typer.echo(data.get("name"))
53
+ except Exception as e:
54
+ typer.echo(f"[colab] Error: {e}")
55
+ raise typer.Exit(1)
56
+
57
+
58
+ def rm(
59
+ session: Annotated[
60
+ Optional[str], typer.Option("-s", "--session", help="Session name")
61
+ ] = None,
62
+ path: Annotated[str, typer.Argument(help="Remote path to remove")] = ...,
63
+ ):
64
+ """Remove a remote file"""
65
+ from colab_cli.common import state
66
+
67
+ name = state.resolve_session(session)
68
+ s = state.store.get(name)
69
+ if not s:
70
+ typer.echo(f"[colab] Session '{name}' not found.")
71
+ raise typer.Exit(1)
72
+ contents = ContentsClient(s)
73
+ try:
74
+ contents.rm(path)
75
+ state.history.log_event(name, "file_operation", {"op": "rm", "path": path})
76
+ typer.echo(f"[colab] Deleted {path}")
77
+ except Exception as e:
78
+ typer.echo(f"[colab] Error: {e}")
79
+ raise typer.Exit(1)
80
+
81
+
82
+ def upload(
83
+ session: Annotated[
84
+ Optional[str], typer.Option("-s", "--session", help="Session name")
85
+ ] = None,
86
+ local_path: Annotated[str, typer.Argument(help="Local file to upload")] = ...,
87
+ remote_path: Annotated[str, typer.Argument(help="Remote path to upload to")] = ...,
88
+ ):
89
+ """Upload a file to a session"""
90
+ from colab_cli.common import state
91
+
92
+ name = state.resolve_session(session)
93
+ s = state.store.get(name)
94
+ if not s:
95
+ typer.echo(f"[colab] Session '{name}' not found.")
96
+ raise typer.Exit(1)
97
+ if not os.path.isfile(local_path):
98
+ typer.echo(f"[colab] Local file '{local_path}' not found.")
99
+ raise typer.Exit(1)
100
+ contents = ContentsClient(s)
101
+ try:
102
+ contents.upload(local_path, remote_path)
103
+ state.history.log_event(
104
+ name,
105
+ "file_operation",
106
+ {"op": "upload", "local": local_path, "remote": remote_path},
107
+ )
108
+ typer.echo(f"[colab] Uploaded '{local_path}' to '{remote_path}'")
109
+ except Exception as e:
110
+ typer.echo(f"[colab] Upload failed: {e}")
111
+ raise typer.Exit(1)
112
+
113
+
114
+ def download(
115
+ session: Annotated[
116
+ Optional[str], typer.Option("-s", "--session", help="Session name")
117
+ ] = None,
118
+ remote_path: Annotated[
119
+ str, typer.Argument(help="Remote path to download from")
120
+ ] = ...,
121
+ local_path: Annotated[
122
+ str, typer.Argument(help="Local path to save the file")
123
+ ] = ...,
124
+ ):
125
+ """Download a file from a session"""
126
+ from colab_cli.common import state
127
+
128
+ name = state.resolve_session(session)
129
+ s = state.store.get(name)
130
+ if not s:
131
+ typer.echo(f"[colab] Session '{name}' not found.")
132
+ raise typer.Exit(1)
133
+ contents = ContentsClient(s)
134
+ try:
135
+ contents.download(remote_path, local_path)
136
+ state.history.log_event(
137
+ name,
138
+ "file_operation",
139
+ {"op": "download", "remote": remote_path, "local": local_path},
140
+ )
141
+ typer.echo(f"[colab] Downloaded '{remote_path}' to '{local_path}'")
142
+ except Exception as e:
143
+ typer.echo(f"[colab] Download failed: {e}")
144
+ raise typer.Exit(1)
145
+
146
+
147
+ def edit(
148
+ session: Annotated[
149
+ Optional[str], typer.Option("-s", "--session", help="Session name")
150
+ ] = None,
151
+ remote_path: Annotated[str, typer.Argument(help="Remote path to edit")] = ...,
152
+ ):
153
+ """Edit a file on a running Colab session"""
154
+ from colab_cli.common import state
155
+
156
+ name = state.resolve_session(session)
157
+ s = state.store.get(name)
158
+ if not s:
159
+ typer.echo(f"[colab] Session '{name}' not found.")
160
+ raise typer.Exit(1)
161
+
162
+ contents = ContentsClient(s)
163
+
164
+ def get_file_hash(path):
165
+ if not os.path.exists(path):
166
+ return None
167
+ with open(path, "rb") as f:
168
+ return hashlib.file_digest(f, "sha256").hexdigest()
169
+
170
+ _, ext = os.path.splitext(remote_path)
171
+
172
+ with tempfile.NamedTemporaryFile(suffix=ext) as tf:
173
+ local_path = tf.name
174
+
175
+ try:
176
+ contents.download(remote_path, local_path)
177
+ except Exception:
178
+ # If download fails, assume file doesn't exist and start empty
179
+ pass
180
+
181
+ hash_before = get_file_hash(local_path)
182
+
183
+ click.edit(filename=local_path)
184
+
185
+ hash_after = get_file_hash(local_path)
186
+
187
+ if hash_after != hash_before:
188
+ contents.upload(local_path, remote_path)
189
+ state.history.log_event(
190
+ name,
191
+ "file_operation",
192
+ {"op": "edit", "remote": remote_path},
193
+ )
194
+ typer.echo(f"[colab] Edited and uploaded '{remote_path}'")
195
+ else:
196
+ typer.echo(f"[colab] No changes made to '{remote_path}'")
197
+
198
+
199
+ def register(app: typer.Typer):
200
+ app.command()(ls)
201
+ app.command()(rm)
202
+ app.command()(upload)
203
+ app.command()(download)
204
+ app.command()(edit)