colabapi 0.1.0__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.
colabapi/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """colabapi: run and reach a persistent Google Colab runtime from your own terminal.
2
+
3
+ The tool never handles your Google password. You sign in to Colab in your own
4
+ browser; colabapi connects to the running runtime over a secure tunnel.
5
+ """
6
+
7
+ __version__ = "0.1.0"
colabapi/cli.py ADDED
@@ -0,0 +1,446 @@
1
+ """colabapi command-line interface.
2
+
3
+ colabapi wraps Google's official `colab` CLI, adding a single command, a systemd
4
+ service, a runtime picker, a live resource monitor, and session-time display.
5
+ Authentication, runtime allocation, and the interactive terminal are delegated to
6
+ Google's own tool (the ban-safe path); this module is the friendly orchestration
7
+ layer on top.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ import time
14
+
15
+ import click
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.prompt import Confirm, Prompt
19
+ from rich.table import Table
20
+
21
+ from . import __version__, monitor as monitor_mod, runtime as rt, service, shellview, timing, ui
22
+ from .colabcli import ColabCLI, ColabCliNotFound, INSTALL_HINT
23
+ from .config import Config, Session, SessionStore, ensure_dirs
24
+ from .keepalive import KeepAlive
25
+
26
+ console = Console()
27
+ colab = ColabCLI()
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # helpers
32
+ # --------------------------------------------------------------------------- #
33
+ def _require_colab() -> None:
34
+ if not colab.available():
35
+ console.print(Panel.fit(INSTALL_HINT, title="Missing dependency", border_style="red"))
36
+ raise SystemExit(1)
37
+
38
+
39
+ def _session_label(s: Session) -> str:
40
+ line = timing.session_line(s.started_at, s.max_lifetime_hours) if s.started_at else ""
41
+ return f"{s.name} [{s.runtime}] {line}".rstrip()
42
+
43
+
44
+ def _pick_session(action: str, name: str | None = None) -> Session:
45
+ """Resolve which session to act on: by explicit name, the only one, or a picker."""
46
+ store = SessionStore.load()
47
+ active = store.active()
48
+ if not active:
49
+ console.print("[red]No active runtime.[/] Start one with [bold]colabapi run[/].")
50
+ raise SystemExit(1)
51
+ if name:
52
+ s = store.get(name)
53
+ if not s:
54
+ console.print(f"[red]No session named '{name}'.[/] "
55
+ f"Known: {', '.join(x.name for x in active) or '(none)'}")
56
+ raise SystemExit(1)
57
+ elif len(active) == 1:
58
+ s = active[0]
59
+ else:
60
+ s = ui.select(active, title=f"Select a session to {action}:", to_label=_session_label)
61
+ if s is None:
62
+ console.print("[dim]Cancelled.[/]")
63
+ raise SystemExit(0)
64
+ colab.session = s.name # target this exact session for all follow-up calls
65
+ return s
66
+
67
+
68
+ def _default_session_name() -> str:
69
+ import uuid
70
+ return "colab-" + uuid.uuid4().hex[:4]
71
+
72
+
73
+ def _sanitize_name(raw: str) -> str:
74
+ keep = "".join(c if (c.isalnum() or c in "-_") else "-" for c in raw.strip())
75
+ return keep.strip("-_") or _default_session_name()
76
+
77
+
78
+ def _run_remote(code: str) -> str:
79
+ res = colab.exec_code(code)
80
+ return res.stdout if res.stdout.strip() else res.stderr
81
+
82
+
83
+ def _max_lifetime_for(runtime_key: str) -> float:
84
+ r = rt.get(runtime_key)
85
+ if r and r.tier in ("pro", "pro+", "paid"):
86
+ return 24.0
87
+ return 12.0
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # group
92
+ # --------------------------------------------------------------------------- #
93
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
94
+ @click.version_option(__version__, prog_name="colabapi")
95
+ def cli() -> None:
96
+ """Run and reach a persistent Google Colab runtime from your own terminal.
97
+
98
+ colabapi never handles your Google password. It drives Google's official
99
+ `colab` CLI: you sign in through Google's own browser flow, and the runtime
100
+ terminal comes over Google's sanctioned tunnel.
101
+ """
102
+ ensure_dirs()
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # login
107
+ # --------------------------------------------------------------------------- #
108
+ @cli.command()
109
+ def login() -> None:
110
+ """Sign in to Colab through Google's browser flow (no password asked)."""
111
+ _require_colab()
112
+ console.print(Panel.fit(
113
+ "colabapi hands off to Google's official sign-in.\n\n"
114
+ "If this is your first sign-in, a browser window opens for [bold]Google's\n"
115
+ "own login[/] (including any 2FA or device checks). [bold]Your password\n"
116
+ "never touches colabapi.[/] If you're already signed in, this just lists\n"
117
+ "your sessions.",
118
+ title="Sign in to Colab", border_style="cyan"))
119
+ code = colab.login()
120
+ if code == 0:
121
+ console.print("[green]Signed in.[/] Next: [bold]colabapi run[/]")
122
+ else:
123
+ console.print(f"[yellow]Sign-in exited with code {code}.[/] Try again or run [bold]colabapi doctor[/].")
124
+
125
+
126
+ # --------------------------------------------------------------------------- #
127
+ # runtimes
128
+ # --------------------------------------------------------------------------- #
129
+ @cli.command()
130
+ def runtimes() -> None:
131
+ """List Colab runtime types and which ones need a paid plan."""
132
+ _print_runtimes()
133
+
134
+
135
+ def _print_runtimes() -> None:
136
+ table = Table(title="Colab runtimes", header_style="bold cyan")
137
+ table.add_column("Key")
138
+ table.add_column("Runtime")
139
+ table.add_column("Accelerator")
140
+ table.add_column("Availability")
141
+ table.add_column("Notes", style="dim")
142
+ for r in rt.RUNTIMES:
143
+ avail = "[green]available[/]" if r.free else \
144
+ f"[yellow]needs {r.tier.upper()}[/] (not on a free account)"
145
+ table.add_row(r.key, r.label, r.accelerator, avail, r.notes)
146
+ console.print(table)
147
+ console.print("[dim]Free-account requests for paid runtimes are refused by Colab itself.[/]")
148
+
149
+
150
+ # --------------------------------------------------------------------------- #
151
+ # run
152
+ # --------------------------------------------------------------------------- #
153
+ @cli.command()
154
+ @click.option("--runtime", "runtime_key", default=None, help="Runtime key (see `colabapi runtimes`).")
155
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompts.")
156
+ def run(runtime_key: str | None, yes: bool) -> None:
157
+ """Allocate a Colab runtime (delegates to `colab new`)."""
158
+ _require_colab()
159
+ cfg = Config.load()
160
+
161
+ _print_runtimes()
162
+ if not runtime_key:
163
+ runtime_key = Prompt.ask("Runtime", default=cfg.default_runtime,
164
+ choices=[r.key for r in rt.RUNTIMES])
165
+ chosen = rt.get(runtime_key)
166
+ if not chosen:
167
+ console.print(f"[red]Unknown runtime '{runtime_key}'.[/]")
168
+ raise SystemExit(1)
169
+
170
+ if not chosen.free and not yes:
171
+ console.print(Panel.fit(
172
+ f"[yellow]{chosen.label}[/] needs [bold]{chosen.tier.upper()}[/]. If your account\n"
173
+ "isn't subscribed, Colab will refuse to allocate it.",
174
+ border_style="yellow"))
175
+ if not Confirm.ask("Continue anyway?", default=False):
176
+ raise SystemExit(0)
177
+
178
+ # Ask the user to name this session; used as `colab new -s <name>` and for
179
+ # every later command (shell / stop / monitor).
180
+ store = SessionStore.load()
181
+ suggested = _default_session_name()
182
+ while True:
183
+ raw = Prompt.ask("Name this session", default=suggested)
184
+ name = _sanitize_name(raw)
185
+ if store.get(name):
186
+ console.print(f"[yellow]You already have a session named '{name}'.[/] Pick another.")
187
+ suggested = _default_session_name()
188
+ continue
189
+ break
190
+
191
+ flags = " ".join(chosen.colab_flags)
192
+ console.print(f"Requesting [cyan]{chosen.label}[/] via [bold]colab new -s {name} {flags}[/]…")
193
+ console.print("[dim]If you're not signed in yet, a browser opens for Google's login first.[/]\n")
194
+ code = colab.new_runtime(chosen.colab_flags, name=name)
195
+ if code != 0:
196
+ console.print(f"\n[red]colab new failed (exit {code}).[/] "
197
+ "Run [bold]colabapi login[/] then retry, or [bold]colabapi doctor[/].")
198
+ raise SystemExit(1)
199
+
200
+ s = Session(runtime=chosen.key, started_at=time.time(),
201
+ max_lifetime_hours=_max_lifetime_for(chosen.key), name=name)
202
+ store.add(s)
203
+ cfg.default_runtime = chosen.key
204
+ cfg.save()
205
+
206
+ console.print(f"[green]Runtime '{name}' ready.[/] {timing.session_line(s.started_at, s.max_lifetime_hours)}")
207
+ console.print(f"\nNow: [bold]colabapi shell[/] · [bold]colabapi monitor[/] · [bold]colabapi stop {name}[/]")
208
+ console.print("[dim]Tip: `colabapi service install` keeps it alive after you log out of this box.[/]")
209
+
210
+
211
+ # --------------------------------------------------------------------------- #
212
+ # shell / repl
213
+ # --------------------------------------------------------------------------- #
214
+ @cli.command()
215
+ @click.argument("name", required=False)
216
+ def shell(name: str | None) -> None:
217
+ """Open a terminal on a session, with a live monitor on top.
218
+
219
+ With no NAME and several sessions active, an arrow-key picker appears.
220
+ """
221
+ _require_colab()
222
+ s = _pick_session("open a shell on", name)
223
+ shellview.open_shell(colab, s.name)
224
+
225
+
226
+ @cli.command()
227
+ @click.argument("name", required=False)
228
+ def repl(name: str | None) -> None:
229
+ """Open an interactive Python REPL on a session (`colab repl`)."""
230
+ _require_colab()
231
+ _pick_session("open a REPL on", name)
232
+ colab.repl()
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # monitor
237
+ # --------------------------------------------------------------------------- #
238
+ @cli.command()
239
+ @click.argument("name", required=False)
240
+ def monitor(name: str | None) -> None:
241
+ """Live CPU / RAM / GPU monitor for a session (Ctrl-C to exit)."""
242
+ _require_colab()
243
+ s = _pick_session("monitor", name)
244
+
245
+ def line() -> str:
246
+ return f"{s.name} · {timing.session_line(s.started_at, s.max_lifetime_hours)}"
247
+
248
+ monitor_mod.live_monitor(_run_remote, line, interval=Config.load().monitor_interval)
249
+ console.print("[dim]Monitor closed.[/]")
250
+
251
+
252
+ # Hidden: the monitor process that runs inside the top tmux pane of `shell`.
253
+ @cli.command(name="_panemonitor", hidden=True)
254
+ @click.argument("name")
255
+ def _panemonitor(name: str) -> None:
256
+ """Internal: live monitor for a single named session (used by `shell`)."""
257
+ colab.session = name
258
+ s = SessionStore.load().get(name)
259
+
260
+ def line() -> str:
261
+ if s and s.started_at:
262
+ return f"{name} · {timing.session_line(s.started_at, s.max_lifetime_hours)}"
263
+ return name
264
+
265
+ monitor_mod.live_monitor(_run_remote, line, interval=max(Config.load().monitor_interval, 2.0))
266
+
267
+
268
+ # --------------------------------------------------------------------------- #
269
+ # status
270
+ # --------------------------------------------------------------------------- #
271
+ @cli.command()
272
+ def sessions() -> None:
273
+ """List the sessions colabapi manages."""
274
+ active = SessionStore.load().active()
275
+ if not active:
276
+ console.print("No active sessions. Start one with [bold]colabapi run[/].")
277
+ return
278
+ table = Table(title="colabapi sessions", header_style="bold cyan")
279
+ table.add_column("Name")
280
+ table.add_column("Runtime")
281
+ table.add_column("Uptime / est. remaining")
282
+ for s in active:
283
+ table.add_row(s.name, s.runtime, timing.session_line(s.started_at, s.max_lifetime_hours))
284
+ console.print(table)
285
+
286
+
287
+ @cli.command()
288
+ @click.argument("name", required=False)
289
+ def status(name: str | None) -> None:
290
+ """Show a session's reachability and estimated time remaining.
291
+
292
+ With no NAME and several sessions active, an arrow-key picker appears.
293
+ """
294
+ s = _pick_session("check", name)
295
+ table = Table(show_header=False, box=None)
296
+ table.add_row("Session", f"[cyan]{s.name}[/]")
297
+ table.add_row("Runtime", s.runtime)
298
+ table.add_row("Uptime / est.", timing.session_line(s.started_at, s.max_lifetime_hours))
299
+ if colab.available():
300
+ res = colab.status() # colab.session already set by _pick_session
301
+ reach = "[green]reachable[/]" if res.ok else "[red]unreachable (session may have ended)[/]"
302
+ table.add_row("colab status", reach)
303
+ if res.text.strip():
304
+ table.add_row("", f"[dim]{res.text.strip().splitlines()[0]}[/]")
305
+ if service.is_installed():
306
+ table.add_row("Service", "installed")
307
+ console.print(Panel(table, title="colabapi status", border_style="cyan"))
308
+
309
+
310
+ # --------------------------------------------------------------------------- #
311
+ # stop
312
+ # --------------------------------------------------------------------------- #
313
+ @cli.command()
314
+ @click.argument("name", required=False)
315
+ @click.option("--keep-remote", is_flag=True,
316
+ help="Only forget the session locally; leave the Colab runtime running.")
317
+ def stop(name: str | None, keep_remote: bool) -> None:
318
+ """Stop a session (`colab stop`) and remove it from colabapi.
319
+
320
+ Give a NAME (e.g. `colabapi stop my-run`), or run `colabapi stop` with no name
321
+ to pick from an arrow-key list of active sessions.
322
+ """
323
+ s = _pick_session("stop", name)
324
+ store = SessionStore.load()
325
+ if not keep_remote and colab.available():
326
+ console.print(f"Stopping [cyan]{s.name}[/] via [bold]colab stop[/]…")
327
+ res = colab.stop_session() # colab.session already set by _pick_session
328
+ if res.ok:
329
+ console.print("[green]Runtime stopped and released.[/]")
330
+ else:
331
+ first = res.text.strip().splitlines()[0] if res.text.strip() else ""
332
+ console.print(f"[yellow]colab stop exited {res.returncode}.[/] {first}")
333
+ console.print("[dim]You can also stop it in the Colab UI (Runtime → Disconnect and delete runtime).[/]")
334
+ store.remove(s.name)
335
+ console.print(f"[green]Removed '{s.name}' from colabapi.[/]")
336
+
337
+
338
+ # --------------------------------------------------------------------------- #
339
+ # daemon (systemd)
340
+ # --------------------------------------------------------------------------- #
341
+ @cli.command()
342
+ @click.argument("name", required=False)
343
+ @click.option("--foreground", is_flag=True, help="Run in the foreground (used by systemd).")
344
+ def daemon(name: str | None, foreground: bool) -> None:
345
+ """Supervisory keep-alive: verify the runtime stays reachable, with backoff.
346
+
347
+ Google's official CLI runs the primary keep-alive against Colab's own tunnel
348
+ endpoint; this daemon is a belt-and-suspenders health check that alerts (via
349
+ the service log) if that stops working, and it never hammers reconnects.
350
+ """
351
+ _require_colab()
352
+ s = _pick_session("supervise", name)
353
+ console.print(f"[cyan]colabapi keep-alive supervisor starting for '{s.name}'[/]")
354
+
355
+ ka = KeepAlive(_run_remote, interval=Config.load().keepalive_interval,
356
+ on_error=lambda e: console.print(f"[dim]keep-alive check failed: {e}[/]"))
357
+ ka.start()
358
+ try:
359
+ while True:
360
+ rem = timing.remaining(s.started_at, s.max_lifetime_hours)
361
+ if rem is not None and rem <= 0:
362
+ console.print("[yellow]Past estimated max lifetime; stopping supervisor.[/]")
363
+ break
364
+ if ka.failures >= 5:
365
+ console.print("[red]Runtime unreachable for several checks; session likely ended.[/]")
366
+ break
367
+ time.sleep(15)
368
+ if not foreground:
369
+ break
370
+ except KeyboardInterrupt:
371
+ pass
372
+ finally:
373
+ ka.stop()
374
+ console.print("[dim]Keep-alive supervisor stopped.[/]")
375
+
376
+
377
+ # --------------------------------------------------------------------------- #
378
+ # doctor / raw
379
+ # --------------------------------------------------------------------------- #
380
+ @cli.command()
381
+ def doctor() -> None:
382
+ """Check the environment and the official `colab` CLI interface."""
383
+ ok = colab.available()
384
+ console.print(f"official colab CLI: {'[green]found[/] at ' + colab.path() if ok else '[red]not found[/]'}")
385
+ if not ok:
386
+ console.print(Panel.fit(INSTALL_HINT, border_style="yellow"))
387
+ return
388
+ console.print(f"colab version: [cyan]{colab.version()}[/]")
389
+ # Surface the live `new` interface so flag drift is visible.
390
+ help_new = colab.help_text("new")
391
+ for flag in ("--gpu", "--tpu"):
392
+ mark = "[green]present[/]" if flag in help_new else "[yellow]NOT found, flag mapping may need update[/]"
393
+ console.print(f"colab new {flag}: {mark}")
394
+ console.print("[dim]If flags differ, edit colabapi/runtime.py (colab_flags); that's the single source.[/]")
395
+
396
+
397
+ @cli.command(context_settings={"ignore_unknown_options": True})
398
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
399
+ def raw(args: tuple[str, ...]) -> None:
400
+ """Passthrough to the official CLI: `colabapi raw -- new --gpu t4`."""
401
+ _require_colab()
402
+ raise SystemExit(colab.raw(list(args)))
403
+
404
+
405
+ # --------------------------------------------------------------------------- #
406
+ # service
407
+ # --------------------------------------------------------------------------- #
408
+ @cli.group(name="service")
409
+ def svc() -> None:
410
+ """Install/manage the colabapi systemd user service."""
411
+
412
+
413
+ @svc.command("install")
414
+ def svc_install() -> None:
415
+ """Install and enable the keep-alive supervisor as a systemd user service."""
416
+ path = service.install(enable=True, start=False)
417
+ console.print(f"[green]Installed[/] {path}")
418
+ console.print("Start it with: [bold]systemctl --user start colabapi[/]")
419
+
420
+
421
+ @svc.command("uninstall")
422
+ def svc_uninstall() -> None:
423
+ """Stop and remove the colabapi systemd service."""
424
+ service.uninstall()
425
+ console.print("[green]Removed the colabapi service.[/]")
426
+
427
+
428
+ @svc.command("status")
429
+ def svc_status() -> None:
430
+ """Show the systemd service status."""
431
+ console.print(service.status())
432
+
433
+
434
+ def main() -> None:
435
+ try:
436
+ cli()
437
+ except ColabCliNotFound as exc:
438
+ console.print(Panel.fit(str(exc), title="Missing dependency", border_style="red"))
439
+ sys.exit(1)
440
+ except KeyboardInterrupt:
441
+ console.print("\n[dim]Interrupted.[/]")
442
+ sys.exit(130)
443
+
444
+
445
+ if __name__ == "__main__":
446
+ main()
colabapi/colabcli.py ADDED
@@ -0,0 +1,219 @@
1
+ """Thin wrapper around Google's official Colab CLI (`colab`).
2
+
3
+ colabapi is an orchestration + persistence + UX layer on top of Google's
4
+ first-party `google-colab-cli` (https://github.com/googlecolab/google-colab-cli).
5
+ We deliberately delegate authentication, runtime allocation, the interactive
6
+ terminal, and the primary keep-alive to Google's own tool, which uses Google's
7
+ sanctioned tunnel and OAuth (the ban-safe path). colabapi adds: a single
8
+ `colabapi` command, a systemd service so sessions survive logout, a runtime
9
+ picker that flags paid tiers, a live resource monitor, and session-time display.
10
+
11
+ Everything that invokes `colab` lives here so that if Google changes the CLI's
12
+ flags, there is exactly one file to update. The mapping below is validated
13
+ against google-colab-cli as documented mid-2026; `colabapi doctor` checks the
14
+ live interface and warns on drift, and every command has a raw passthrough
15
+ escape hatch (`colabapi raw -- ...`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from dataclasses import dataclass
25
+ from typing import Optional, Sequence
26
+
27
+ # Env override lets users point at a differently-named binary or a wrapper.
28
+ BINARY_ENV = "COLABAPI_COLAB_BIN"
29
+ DEFAULT_BINARY = "colab"
30
+
31
+ # The official CLI ships as a dependency of colabapi, so this should normally
32
+ # never be seen. It can only appear on a broken/partial install.
33
+ INSTALL_HINT = (
34
+ "The official Google Colab CLI (`colab`) could not be found.\n"
35
+ "It ships as a dependency of colabapi, so reinstalling should fix this:\n"
36
+ " pipx install --force colabapi\n"
37
+ "If you installed from source, run: pip install -e .\n"
38
+ "You can also point colabapi at a specific binary: export COLABAPI_COLAB_BIN=/path/to/colab"
39
+ )
40
+
41
+
42
+ class ColabCliNotFound(RuntimeError):
43
+ pass
44
+
45
+
46
+ @dataclass
47
+ class ColabResult:
48
+ returncode: int
49
+ stdout: str
50
+ stderr: str
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return self.returncode == 0
55
+
56
+ @property
57
+ def text(self) -> str:
58
+ return self.stdout if self.stdout.strip() else self.stderr
59
+
60
+
61
+ class ColabCLI:
62
+ def __init__(self, binary: Optional[str] = None):
63
+ self.binary = binary or os.environ.get(BINARY_ENV) or DEFAULT_BINARY
64
+ # When set, every session-scoped command targets this named `colab`
65
+ # session via `-s`, so colabapi keeps working even if the account has
66
+ # other sessions running. Set by new_runtime / when a Session is loaded.
67
+ self.session: Optional[str] = None
68
+
69
+ # -- discovery -----------------------------------------------------------
70
+ def path(self) -> Optional[str]:
71
+ """Locate the `colab` executable.
72
+
73
+ Order matters: because google-colab-cli is a dependency of colabapi, its
74
+ `colab` console script lives in the *same* environment bin dir as the
75
+ running interpreter (this is where pipx/venv/pip --user put it), but pipx
76
+ does NOT expose dependency scripts on PATH. So we look next to
77
+ sys.executable first, which makes `pipx install colabapi` self-contained,
78
+ then fall back to PATH for dev setups or an external install.
79
+ """
80
+ exe_dir = os.path.dirname(sys.executable or "")
81
+ if exe_dir:
82
+ for fname in (self.binary, self.binary + ".exe"):
83
+ cand = os.path.join(exe_dir, fname)
84
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
85
+ return cand
86
+ return shutil.which(self.binary)
87
+
88
+ def require(self) -> str:
89
+ p = self.path()
90
+ if not p:
91
+ raise ColabCliNotFound(INSTALL_HINT)
92
+ return p
93
+
94
+ def available(self) -> bool:
95
+ return self.path() is not None
96
+
97
+ # -- low-level invocation ------------------------------------------------
98
+ def _child_env(self) -> dict:
99
+ """Environment for the `colab` subprocess.
100
+
101
+ Google frequently returns OAuth scopes in a different order (or a subset)
102
+ than the official CLI requested. When that happens, the `oauthlib` library
103
+ the CLI depends on raises "Scope has changed" and aborts sign-in even
104
+ though authentication actually succeeded. Setting OAUTHLIB_RELAX_TOKEN_SCOPE
105
+ tells oauthlib to accept the returned token, so login completes. We only
106
+ set a default; a user who exported their own value wins.
107
+ """
108
+ env = os.environ.copy()
109
+ env.setdefault("OAUTHLIB_RELAX_TOKEN_SCOPE", "1")
110
+ return env
111
+
112
+ def _run(self, args: Sequence[str], capture: bool = True,
113
+ timeout: Optional[float] = None,
114
+ input: Optional[str] = None) -> ColabResult:
115
+ # Use the resolved absolute path, not the bare name: when colabapi is
116
+ # installed via pipx, `colab` lives in colabapi's venv but is NOT on PATH.
117
+ exe = self.require()
118
+ proc = subprocess.run(
119
+ [exe, *args],
120
+ capture_output=capture,
121
+ text=True,
122
+ timeout=timeout,
123
+ input=input,
124
+ env=self._child_env(),
125
+ )
126
+ return ColabResult(proc.returncode,
127
+ proc.stdout or "" if capture else "",
128
+ proc.stderr or "" if capture else "")
129
+
130
+ def _exec_tty(self, args: Sequence[str]) -> int:
131
+ """Hand the terminal directly to `colab` for interactive subcommands.
132
+
133
+ Using subprocess with inherited stdio gives the user Google's real
134
+ PTY/keepalive behavior for `console`/`repl` without us re-implementing
135
+ terminal handling. Invoked via the resolved absolute path (see _run).
136
+ """
137
+ exe = self.require()
138
+ proc = subprocess.run([exe, *args], env=self._child_env()) # inherits stdin/out/err
139
+ return proc.returncode
140
+
141
+ def _session_args(self) -> list:
142
+ """`-s <name>` for the colabapi-owned session, or nothing if unset."""
143
+ return ["-s", self.session] if self.session else []
144
+
145
+ # -- high-level commands (the single place flags are mapped) --------------
146
+ def version(self) -> str:
147
+ # The official CLI exposes version as a subcommand (`colab version`),
148
+ # not a `--version` flag, and prints e.g. "Version: 0.6.0".
149
+ try:
150
+ out = self._run(["version"], timeout=15).text.strip()
151
+ return out.split(":", 1)[1].strip() if ":" in out else out
152
+ except Exception:
153
+ return "unknown"
154
+
155
+ def help_text(self, subcommand: Optional[str] = None) -> str:
156
+ args = ([subcommand] if subcommand else []) + ["--help"]
157
+ try:
158
+ return self._run(args, timeout=15).text
159
+ except Exception as exc: # noqa: BLE001
160
+ return f"(could not read help: {exc})"
161
+
162
+ def login(self) -> int:
163
+ """Trigger Google's browser OAuth.
164
+
165
+ The official CLI has no standalone `auth` command; OAuth happens on the
166
+ first call that reaches Colab's backend. We use `colab sessions` (list
167
+ sessions, which needs credentials) as a lightweight trigger that does NOT
168
+ allocate a VM, run with an inherited TTY so Google's interactive login /
169
+ 2FA / device checks work normally. If sign-in is still needed, it also
170
+ completes automatically on the first `colab new` (see new_runtime).
171
+ """
172
+ return self._exec_tty(["sessions"])
173
+
174
+ def new_runtime(self, colab_flags: Sequence[str], name: Optional[str] = None) -> int:
175
+ """Allocate a runtime via `colab new`, interactively.
176
+
177
+ Run with an inherited TTY (not captured) so that if this is the first
178
+ authenticated call, Google's browser OAuth flow runs correctly, and the
179
+ user sees colab's own progress output. Returns the exit code.
180
+ `colab_flags` is the accelerator mapping from runtime.py. When `name` is
181
+ given we create the session as `-s <name>` and remember it, so every
182
+ later command can target exactly this session even if others exist.
183
+ """
184
+ args = ["new"]
185
+ if name:
186
+ args += ["-s", name]
187
+ args += list(colab_flags)
188
+ code = self._exec_tty(args)
189
+ if name and code == 0:
190
+ self.session = name
191
+ return code
192
+
193
+ def status(self) -> ColabResult:
194
+ return self._run(["status", *self._session_args()], timeout=30)
195
+
196
+ def stop_session(self) -> ColabResult:
197
+ """Stop and release the runtime (`colab stop`), targeting our session."""
198
+ return self._run(["stop", *self._session_args()], timeout=60)
199
+
200
+ def console(self) -> int:
201
+ """Interactive PTY shell on the runtime (Google's `colab console`)."""
202
+ return self._exec_tty(["console", *self._session_args()])
203
+
204
+ def repl(self) -> int:
205
+ return self._exec_tty(["repl", *self._session_args()])
206
+
207
+ def exec_code(self, code: str, timeout: float = 40) -> ColabResult:
208
+ """Run Python code on the runtime and capture its stdout.
209
+
210
+ `colab exec` executes Python read from stdin (the documented form is
211
+ `echo '<py>' | colab exec`); it is NOT a shell. The resource monitor and
212
+ keep-alive send small Python snippets through here. With a single active
213
+ session the CLI infers it, so no `--session` is needed.
214
+ """
215
+ return self._run(["exec", *self._session_args()], timeout=timeout, input=code)
216
+
217
+ def raw(self, args: Sequence[str]) -> int:
218
+ """Passthrough escape hatch: `colabapi raw -- <args>` -> `colab <args>`."""
219
+ return self._exec_tty(list(args))