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 +7 -0
- colabapi/cli.py +446 -0
- colabapi/colabcli.py +219 -0
- colabapi/config.py +148 -0
- colabapi/keepalive.py +64 -0
- colabapi/monitor.py +145 -0
- colabapi/runtime.py +85 -0
- colabapi/service.py +73 -0
- colabapi/shellview.py +108 -0
- colabapi/timing.py +53 -0
- colabapi/ui.py +102 -0
- colabapi-0.1.0.dist-info/METADATA +232 -0
- colabapi-0.1.0.dist-info/RECORD +17 -0
- colabapi-0.1.0.dist-info/WHEEL +5 -0
- colabapi-0.1.0.dist-info/entry_points.txt +2 -0
- colabapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- colabapi-0.1.0.dist-info/top_level.txt +1 -0
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))
|