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,365 @@
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 platform
16
+ from typing import Optional
17
+
18
+ import typer
19
+ from typing_extensions import Annotated
20
+
21
+ from colab_cli import auto_update
22
+ from colab_cli.auto_update import get_app_version
23
+ from colab_cli.common import state
24
+
25
+
26
+ def pay():
27
+ """Open the Colab signup page to manage compute units"""
28
+ import webbrowser
29
+
30
+ url = "https://colab.research.google.com/signup"
31
+ typer.echo(f"[colab] Opening {url}...")
32
+ webbrowser.open(url)
33
+
34
+
35
+ def url(
36
+ session: Annotated[
37
+ Optional[str], typer.Option("-s", "--session", help="Session name")
38
+ ] = None,
39
+ host: Annotated[
40
+ str,
41
+ typer.Option(
42
+ "--host",
43
+ help=(
44
+ "Colab frontend host (origin) to use for the URL. The Colab "
45
+ "frontend resolves `dbu` against `window.location.origin`, "
46
+ "so this only changes the page origin, not the embedded "
47
+ "backend path."
48
+ ),
49
+ ),
50
+ ] = "https://colab.research.google.com",
51
+ open_browser: Annotated[
52
+ bool,
53
+ typer.Option(
54
+ "--open",
55
+ help=(
56
+ "After printing the URL, also open it in the system browser. "
57
+ "Off by default so the command remains pipeable "
58
+ "(e.g. `colab url -s s1 | xclip`)."
59
+ ),
60
+ ),
61
+ ] = False,
62
+ ):
63
+ """Print a browser URL that connects to an existing session.
64
+
65
+ Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``,
66
+ where the path is ``/tun/m/<endpoint>``. When opened, the Colab frontend
67
+ skips ``/tun/m/assign`` and attaches the kernel to our existing VM.
68
+
69
+ The ``dbu`` query parameter is the ``datalab_backend_url`` development
70
+ flag. Because it's a development flag, URL-overriding it may be gated
71
+ by the Colab frontend; some users may need to use the hash-based
72
+ ``#datalabBackendUrl=...`` form instead.
73
+ """
74
+ # Imported here (not at module top) to mirror the lazy-state pattern used
75
+ # elsewhere in this module and avoid a circular import via colab_cli.common.
76
+ from urllib.parse import quote
77
+
78
+ from colab_cli.common import state
79
+
80
+ name = state.resolve_session(session)
81
+ s = state.store.get(name)
82
+ if not s:
83
+ typer.echo(f"[colab] Session '{name}' not found.", err=True)
84
+ raise typer.Exit(code=1)
85
+
86
+ # Strip a trailing slash so we don't produce `https://host//notebooks/...`.
87
+ host_clean = host.rstrip("/")
88
+ # `dbu` value is the path `/tun/m/<endpoint>`. URL-encode it (incl. the
89
+ # slashes via `safe=""`) so the value survives any downstream non-strict
90
+ # query-string re-parsing — this is also the form shown in real Colab
91
+ # connect URLs in the wild.
92
+ dbu_value = quote(f"/tun/m/{s.endpoint}", safe="")
93
+ connect_url = f"{host_clean}/notebooks/empty.ipynb?dbu={dbu_value}"
94
+
95
+ # Print the URL on its own line with no `[colab]` prefix so the output
96
+ # is pipeable (`colab url -s s1 | xclip`, etc.).
97
+ typer.echo(connect_url)
98
+
99
+ if open_browser:
100
+ import webbrowser
101
+
102
+ webbrowser.open(connect_url)
103
+
104
+
105
+ def log(
106
+ session: Annotated[
107
+ Optional[str],
108
+ typer.Option(
109
+ "-s",
110
+ "--session",
111
+ help="Session name (if omitted, lists all sessions with logs)",
112
+ ),
113
+ ] = None,
114
+ lines: Annotated[
115
+ Optional[int],
116
+ typer.Option(
117
+ "-n", "--lines", help="Number of lines to show/export (default: all)"
118
+ ),
119
+ ] = None,
120
+ type: Annotated[
121
+ Optional[str],
122
+ typer.Option(
123
+ "-t",
124
+ "--type",
125
+ help="Filter by event type (e.g., execution, file_operation)",
126
+ ),
127
+ ] = None,
128
+ output: Annotated[
129
+ Optional[str],
130
+ typer.Option(
131
+ "-o",
132
+ "--output",
133
+ help="Output file path (suffix determines format: .ipynb, .md, .txt, .jsonl)",
134
+ ),
135
+ ] = None,
136
+ ):
137
+ """Manage and view session history logs"""
138
+ if not session:
139
+ sessions_with_logs = state.history.list_sessions()
140
+ if not sessions_with_logs:
141
+ typer.echo("[colab] No session history found.")
142
+ else:
143
+ typer.echo("[colab] Sessions with history logs:")
144
+ for n in sorted(sessions_with_logs):
145
+ typer.echo(f" {n}")
146
+ return
147
+
148
+ events = state.history.get_history(session)
149
+ if not events:
150
+ typer.echo(f"[colab] No history found for session '{session}'.")
151
+ return
152
+
153
+ if type:
154
+ events = [e for e in events if e.get("event_type") == type]
155
+
156
+ if lines:
157
+ events = events[-lines:]
158
+
159
+ if output:
160
+ from colab_cli.converter import export_history
161
+
162
+ export_history(events, session, output)
163
+ else:
164
+ for event in events:
165
+ ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
166
+ etype = event.get("event_type", "unknown")
167
+
168
+ if etype == "execution":
169
+ preview = event.get("code", "").strip().split("\n")[0][:60]
170
+ typer.echo(f"[{ts}] EXEC: {preview}...")
171
+ elif etype == "file_operation":
172
+ typer.echo(
173
+ f"[{ts}] FILE: {event.get('op')} {event.get('path', event.get('remote', ''))}"
174
+ )
175
+ elif etype == "automation":
176
+ typer.echo(f"[{ts}] AUTO: {event.get('op')}")
177
+ elif etype == "stdin_request":
178
+ typer.echo(f"[{ts}] INPT: {event.get('prompt', '').strip()}")
179
+ elif etype == "input_reply":
180
+ typer.echo(f"[{ts}] RPLY: {event.get('value', '').strip()}")
181
+ elif etype == "keep_alive_started":
182
+ typer.echo(
183
+ f"[{ts}] KEEP: started endpoint={event.get('endpoint')} pid={event.get('pid')}"
184
+ )
185
+ elif etype == "keep_alive_error":
186
+ msg = (
187
+ f"[{ts}] KEEP: error iter={event.get('iteration')} "
188
+ f"status={event.get('status_code')} "
189
+ f"type={event.get('error_type')} "
190
+ f"msg={event.get('error', '')[:120]}"
191
+ )
192
+ body = event.get("response_body")
193
+ if body:
194
+ msg += f" body={body[:300]}"
195
+ typer.echo(msg)
196
+ elif etype == "keep_alive_stopped":
197
+ msg = (
198
+ f"[{ts}] KEEP: stopped reason={event.get('reason')} "
199
+ f"iters={event.get('iterations')} "
200
+ f"duration={event.get('duration_seconds')}s"
201
+ )
202
+ last_err = event.get("last_error")
203
+ if last_err:
204
+ msg += (
205
+ f" last_error=[status={last_err.get('status_code')} "
206
+ f"type={last_err.get('error_type')} "
207
+ f"msg={str(last_err.get('error', ''))[:120]}]"
208
+ )
209
+ if event.get("expected_endpoint") or event.get("actual_endpoint"):
210
+ msg += (
211
+ f" expected={event.get('expected_endpoint')} "
212
+ f"actual={event.get('actual_endpoint')}"
213
+ )
214
+ typer.echo(msg)
215
+ else:
216
+ typer.echo(f"[{ts}] EVENT: {etype}")
217
+
218
+
219
+ def whoami():
220
+ """[debug] Print the active credentials' identity, scopes, and expiry.
221
+
222
+ Mints an access token using the same path the rest of the CLI uses
223
+ (`auth.get_credentials(...)` honoring the global `--auth=...` flag),
224
+ then queries Google's tokeninfo endpoint and prints a human-readable
225
+ summary. Useful when debugging "why is my call to
226
+ colab.pa.googleapis.com 403-ing" — the answer is almost always a
227
+ missing scope or a token whose `email` doesn't match what you
228
+ expected.
229
+
230
+ Hidden from `colab --help` because end users shouldn't need it; reach
231
+ it via `colab whoami --help` or by knowing the name.
232
+ """
233
+ import json
234
+ import urllib.error
235
+ import urllib.parse
236
+ import urllib.request
237
+
238
+ from colab_cli.auth import get_credentials
239
+
240
+ provider = state.auth_provider
241
+
242
+ # Mint a fresh token. Some credential types (service-account, GCE, some
243
+ # impersonated creds) don't populate `.token` until refresh() is called,
244
+ # so we always refresh — cheap, ~1 RPC, and avoids a confusing
245
+ # `creds.token is None` failure mode for valid credentials.
246
+ sess = get_credentials(state.client_oauth_config, provider=provider)
247
+ creds = sess.credentials
248
+ try:
249
+ from google.auth.transport.requests import Request as _GoogleAuthRequest
250
+
251
+ creds.refresh(_GoogleAuthRequest())
252
+ except Exception as e:
253
+ typer.echo(f"[colab] whoami: failed to refresh credentials: {e}", err=True)
254
+ raise typer.Exit(code=1)
255
+
256
+ token = creds.token
257
+ if not token:
258
+ typer.echo(
259
+ "[colab] whoami: credentials have no access token after refresh; "
260
+ "the auth provider may have failed silently.",
261
+ err=True,
262
+ )
263
+ raise typer.Exit(code=1)
264
+
265
+ # Hit Google's tokeninfo endpoint. We use stdlib urllib (rather than the
266
+ # already-authorized `sess`) deliberately: tokeninfo accepts the token as
267
+ # a query parameter and does NOT want a Bearer header alongside it.
268
+ qs = urllib.parse.urlencode({"access_token": token})
269
+ url = f"https://oauth2.googleapis.com/tokeninfo?{qs}"
270
+ try:
271
+ with urllib.request.urlopen(url, timeout=10) as resp:
272
+ body = resp.read().decode("utf-8")
273
+ info = json.loads(body)
274
+ except urllib.error.HTTPError as e:
275
+ # tokeninfo returns 400 for invalid/expired/revoked tokens with a
276
+ # JSON body like {"error":"invalid_token","error_description":"..."}.
277
+ # Surface that body so the developer can see *why* it was rejected.
278
+ try:
279
+ err_body = e.read().decode("utf-8")
280
+ except Exception:
281
+ err_body = ""
282
+ typer.echo(
283
+ f"[colab] whoami: tokeninfo returned HTTP {e.code}: {err_body or e.reason}",
284
+ err=True,
285
+ )
286
+ raise typer.Exit(code=1)
287
+ except Exception as e:
288
+ typer.echo(f"[colab] whoami: tokeninfo request failed: {e}", err=True)
289
+ raise typer.Exit(code=1)
290
+
291
+ # Format. Provider name from the AuthProvider enum (e.g. "adc"); email
292
+ # may be missing for tokens scoped without `userinfo.email`, in which
293
+ # case we say so explicitly rather than printing "Email: None".
294
+ email = info.get("email") or "<unavailable: token has no userinfo.email scope>"
295
+ expires_in = info.get("expires_in")
296
+ try:
297
+ expires_min = int(expires_in) // 60
298
+ expires_str = f"{expires_min}m"
299
+ except (TypeError, ValueError):
300
+ expires_str = str(expires_in) if expires_in else "<unknown>"
301
+
302
+ audience = info.get("audience") or info.get("aud") or "<none>"
303
+ scopes = (info.get("scope") or "").split()
304
+
305
+ typer.echo(f"Auth provider: {provider.value}")
306
+ typer.echo(f"Email: {email}")
307
+ typer.echo(f"Audience: {audience}")
308
+ typer.echo(f"Expires in: {expires_str}")
309
+ if scopes:
310
+ typer.echo("Scopes:")
311
+ for s in sorted(scopes):
312
+ typer.echo(f" - {s}")
313
+ else:
314
+ typer.echo("Scopes: <none>")
315
+
316
+
317
+ def version_command():
318
+ """Show the version of the Colab CLI"""
319
+ typer.echo(f"Version: {get_app_version()}")
320
+
321
+
322
+ def update_command(
323
+ install: Annotated[
324
+ bool,
325
+ typer.Option(
326
+ "--install",
327
+ help=(
328
+ "After checking, run 'pip install -U google-colab-cli' to "
329
+ "upgrade the CLI in place. No-op if already up to date. "
330
+ "Linux only."
331
+ ),
332
+ ),
333
+ ] = False,
334
+ ):
335
+ """Check for latest version and print if an update is available"""
336
+ auto_update.check_for_updates(quiet=False)
337
+ if not install:
338
+ return
339
+
340
+ if platform.system() != "Linux":
341
+ typer.echo(
342
+ "[colab] '--install' self-install is only supported on Linux.", err=True
343
+ )
344
+ raise typer.Exit(code=1)
345
+
346
+ # Skip the install when the current version already matches (or exceeds)
347
+ # the latest known version, to avoid an unnecessary pip subprocess.
348
+ settings = state.settings_store.load()
349
+ if settings.latest_version and not auto_update._is_newer(
350
+ settings.latest_version, auto_update.get_app_version()
351
+ ):
352
+ return
353
+
354
+ auto_update.self_install()
355
+
356
+
357
+ def register(app: typer.Typer):
358
+ app.command()(pay)
359
+ app.command()(log)
360
+ app.command(name="url")(url)
361
+ app.command(name="version")(version_command)
362
+ app.command(name="update")(update_command)
363
+ # Developer-only debugging aid; hidden from `colab --help` but still
364
+ # reachable via `colab whoami` / `colab whoami --help`.
365
+ app.command(name="whoami", hidden=True)(whoami)
colab_cli/common.py ADDED
@@ -0,0 +1,185 @@
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 os
17
+ import signal
18
+ import sys
19
+ import time
20
+ from typing import Optional
21
+
22
+ import typer
23
+
24
+ from colab_cli.auth import AuthProvider, get_credentials
25
+ from colab_cli.client import Client, Prod
26
+ from colab_cli.history import HistoryLogger
27
+ from colab_cli.state import StateStore, SettingsStore
28
+
29
+
30
+ class State:
31
+ def __init__(self):
32
+ self.client_oauth_config = os.path.expanduser("~/.colab-cli-oauth-config.json")
33
+ self.config_path = None
34
+ self.logtostderr = False
35
+ self.auth_provider = AuthProvider.OAUTH2
36
+ self._client = None
37
+ self._store = None
38
+ self._settings_store = None
39
+ self._history = None
40
+ self._sessions = None
41
+
42
+ @property
43
+ def store(self):
44
+ if self._store is None:
45
+ self._store = StateStore(self.config_path)
46
+ return self._store
47
+
48
+ @property
49
+ def settings_store(self):
50
+ if self._settings_store is None:
51
+ # We don't currently allow overriding settings path via CLI,
52
+ # but we could if needed. For now, use default.
53
+ self._settings_store = SettingsStore()
54
+ return self._settings_store
55
+
56
+ @property
57
+ def history(self):
58
+ if self._history is None:
59
+ self._history = HistoryLogger()
60
+ return self._history
61
+
62
+ @property
63
+ def client(self):
64
+ if self._client is None:
65
+ creds = get_credentials(
66
+ self.client_oauth_config, provider=self.auth_provider
67
+ )
68
+ self._client = Client(Prod(), creds)
69
+ return self._client
70
+
71
+ def prune_session(self, name: str):
72
+ """Removes a session from local state and kills its keep-alive process."""
73
+ s = self.store.get(name)
74
+ if s and s.keep_alive_pid:
75
+ kill_process(s.keep_alive_pid)
76
+ self.store.remove(name)
77
+ if self._sessions and name in self._sessions:
78
+ del self._sessions[name]
79
+ self.history.log_event(name, "session_terminated", {"reason": "pruned"})
80
+
81
+ def sync_sessions(self):
82
+ if self._sessions is not None:
83
+ return self._sessions, self.client.list_assignments()
84
+
85
+ # Check local store first. If it's empty, we don't necessarily need to hit the backend
86
+ # unless we are specifically looking for server-side assignments (e.g. 'colab sessions').
87
+ local_sessions = self.store.list()
88
+ if not local_sessions:
89
+ self._sessions = {}
90
+ # We still need to return assignments for 'colab sessions' to work
91
+ # But we only trigger client creation (and thus auth) if we have to.
92
+ try:
93
+ assignments = self.client.list_assignments()
94
+ except SystemExit:
95
+ # If auth fails, we just return empty assignments
96
+ assignments = []
97
+ return self._sessions, assignments
98
+
99
+ assignments = self.client.list_assignments()
100
+ active_endpoints = {a.endpoint for a in assignments}
101
+
102
+ self._sessions = local_sessions
103
+ pruned = 0
104
+ for name, s in list(self._sessions.items()):
105
+ if s.endpoint not in active_endpoints:
106
+ self.prune_session(name)
107
+ pruned += 1
108
+
109
+ if pruned > 0:
110
+ typer.echo(f"[colab] Pruned {pruned} stale local session(s).")
111
+
112
+ return self._sessions, assignments
113
+
114
+ def resolve_session(self, session_name: Optional[str]) -> str:
115
+ if session_name:
116
+ return session_name
117
+
118
+ # Check local store first to avoid hitting the backend (and triggering auth) if we don't have to
119
+ local_sessions = self.store.list()
120
+ if not local_sessions:
121
+ typer.echo(
122
+ "[colab] Error: No active sessions found. Create one with 'colab new'."
123
+ )
124
+ raise typer.Exit(1)
125
+
126
+ # If we have local sessions, we need to sync to make sure they are still valid.
127
+ # This will trigger auth if valid credentials are not present.
128
+ sessions, _ = self.sync_sessions()
129
+ active_names = list(sessions.keys())
130
+
131
+ if len(active_names) == 1:
132
+ name = active_names[0]
133
+ typer.echo(f"[colab] Using unique session '{name}'.")
134
+ return name
135
+ elif len(active_names) > 1:
136
+ typer.echo(
137
+ f"[colab] Error: Multiple active sessions found. Specify one with -s: {', '.join(active_names)}"
138
+ )
139
+ raise typer.Exit(1)
140
+ else:
141
+ typer.echo(
142
+ "[colab] Error: No active sessions found. Create one with 'colab new'."
143
+ )
144
+ raise typer.Exit(1)
145
+
146
+
147
+ state = State()
148
+
149
+
150
+ def kill_process(pid: int):
151
+ """Safely terminates a process by PID."""
152
+ if not pid:
153
+ return
154
+ try:
155
+ os.kill(pid, signal.SIGTERM)
156
+ # Give it a moment to exit
157
+ for _ in range(5):
158
+ time.sleep(0.1)
159
+ os.kill(pid, 0)
160
+ except OSError:
161
+ # Already dead
162
+ pass
163
+ except Exception:
164
+ logging.debug(f"Failed to kill process {pid}")
165
+
166
+
167
+ def setup_logging(log_to_stderr: bool):
168
+ log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
169
+ logger = logging.getLogger()
170
+ logger.setLevel(logging.DEBUG)
171
+
172
+ requests_log = logging.getLogger("urllib3")
173
+ requests_log.setLevel(logging.DEBUG)
174
+ requests_log.propagate = True
175
+
176
+ log_dir = os.path.expanduser("~/.config/colab-cli")
177
+ os.makedirs(log_dir, exist_ok=True)
178
+ file_handler = logging.FileHandler(os.path.join(log_dir, "colab.log"))
179
+ file_handler.setFormatter(logging.Formatter(log_format))
180
+ logger.addHandler(file_handler)
181
+
182
+ if log_to_stderr:
183
+ stream_handler = logging.StreamHandler(sys.stderr)
184
+ stream_handler.setFormatter(logging.Formatter(log_format))
185
+ logger.addHandler(stream_handler)