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/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """Local configuration and session state.
2
+
3
+ Everything colabapi stores lives under the user's XDG config/state directories.
4
+ The only thing ever written to disk is non-sensitive session metadata (chosen
5
+ runtime, the `colab` session name, timestamps) plus a couple of preferences. No
6
+ Google credentials are ever requested, transmitted, or stored. See README
7
+ "Privacy".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+
19
+ def _xdg(env: str, default: Path) -> Path:
20
+ value = os.environ.get(env)
21
+ return Path(value) if value else default
22
+
23
+
24
+ HOME = Path.home()
25
+ CONFIG_DIR = _xdg("XDG_CONFIG_HOME", HOME / ".config") / "colabapi"
26
+ STATE_DIR = _xdg("XDG_STATE_HOME", HOME / ".local" / "state") / "colabapi"
27
+ DATA_DIR = _xdg("XDG_DATA_HOME", HOME / ".local" / "share") / "colabapi"
28
+
29
+ CONFIG_FILE = CONFIG_DIR / "config.json"
30
+ SESSION_FILE = STATE_DIR / "session.json" # legacy single-session file (migrated)
31
+ SESSIONS_FILE = STATE_DIR / "sessions.json" # current multi-session registry
32
+
33
+
34
+ def ensure_dirs() -> None:
35
+ for d in (CONFIG_DIR, STATE_DIR, DATA_DIR):
36
+ d.mkdir(parents=True, exist_ok=True)
37
+ # Keep the tree private (config may hold non-Google tokens like preferences).
38
+ try:
39
+ os.chmod(DATA_DIR, 0o700)
40
+ os.chmod(STATE_DIR, 0o700)
41
+ except OSError:
42
+ pass
43
+
44
+
45
+ @dataclass
46
+ class Config:
47
+ """User preferences. Never contains Google credentials."""
48
+
49
+ default_runtime: str = "cpu"
50
+ keepalive_interval: int = 60 # seconds between supervisory keep-alive checks
51
+ monitor_interval: float = 2.0 # seconds between resource refreshes
52
+
53
+ @classmethod
54
+ def load(cls) -> "Config":
55
+ if CONFIG_FILE.exists():
56
+ data = json.loads(CONFIG_FILE.read_text())
57
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
58
+ return cls(**known)
59
+ return cls()
60
+
61
+ def save(self) -> None:
62
+ ensure_dirs()
63
+ CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2))
64
+
65
+
66
+ @dataclass
67
+ class Session:
68
+ """Local bookkeeping for the runtime `colab` currently has active.
69
+
70
+ The official CLI owns the real connection; colabapi only records which
71
+ runtime we asked for and when, so it can show uptime / estimated time left.
72
+ """
73
+
74
+ runtime: str = "cpu"
75
+ started_at: Optional[float] = None # epoch seconds when `colab new` succeeded
76
+ max_lifetime_hours: float = 12.0 # absolute Colab cap (informational estimate)
77
+ name: Optional[str] = None # the `colab` session name colabapi created (passed as -s)
78
+ extra: dict[str, Any] = field(default_factory=dict)
79
+
80
+ @classmethod
81
+ def load(cls) -> Optional["Session"]:
82
+ if not SESSION_FILE.exists():
83
+ return None
84
+ data = json.loads(SESSION_FILE.read_text())
85
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
86
+ return cls(**known)
87
+
88
+ def save(self) -> None:
89
+ ensure_dirs()
90
+ SESSION_FILE.write_text(json.dumps(asdict(self), indent=2))
91
+
92
+ @staticmethod
93
+ def clear() -> None:
94
+ SESSION_FILE.unlink(missing_ok=True)
95
+
96
+ @property
97
+ def is_active(self) -> bool:
98
+ return self.started_at is not None
99
+
100
+
101
+ @dataclass
102
+ class SessionStore:
103
+ """Registry of every named session colabapi currently manages.
104
+
105
+ colabapi can own several `colab` sessions at once (each created via
106
+ `colabapi run` with its own name). This is the single source of truth for the
107
+ session pickers used by shell / stop / monitor.
108
+ """
109
+
110
+ sessions: list[Session] = field(default_factory=list)
111
+
112
+ @classmethod
113
+ def load(cls) -> "SessionStore":
114
+ if SESSIONS_FILE.exists():
115
+ data = json.loads(SESSIONS_FILE.read_text())
116
+ items = []
117
+ for d in data.get("sessions", []):
118
+ known = {k: v for k, v in d.items() if k in Session.__dataclass_fields__}
119
+ items.append(Session(**known))
120
+ return cls(items)
121
+ # One-time migration from the legacy single-session file.
122
+ legacy = Session.load()
123
+ store = cls([legacy] if (legacy and legacy.is_active) else [])
124
+ if legacy:
125
+ store.save()
126
+ Session.clear()
127
+ return store
128
+
129
+ def save(self) -> None:
130
+ ensure_dirs()
131
+ SESSIONS_FILE.write_text(
132
+ json.dumps({"sessions": [asdict(s) for s in self.sessions]}, indent=2)
133
+ )
134
+
135
+ def add(self, session: Session) -> None:
136
+ self.sessions = [s for s in self.sessions if s.name != session.name]
137
+ self.sessions.append(session)
138
+ self.save()
139
+
140
+ def remove(self, name: str) -> None:
141
+ self.sessions = [s for s in self.sessions if s.name != name]
142
+ self.save()
143
+
144
+ def get(self, name: str) -> Optional[Session]:
145
+ return next((s for s in self.sessions if s.name == name), None)
146
+
147
+ def active(self) -> list[Session]:
148
+ return [s for s in self.sessions if s.is_active and s.name]
colabapi/keepalive.py ADDED
@@ -0,0 +1,64 @@
1
+ """Supervisory keep-alive health check.
2
+
3
+ The *primary* keep-alive is Google's own: the official `colab` CLI pings Colab's
4
+ sanctioned tunnel endpoint on an interval to hold off the idle timeout. This
5
+ module is only a belt-and-suspenders supervisor. It runs a trivial command on
6
+ the runtime once a minute to confirm the session is still reachable, and records
7
+ failures so the systemd service log surfaces a silent death of Google's daemon.
8
+
9
+ It addresses the IDLE timeout only; it cannot and does not try to defeat Colab's
10
+ absolute max-lifetime cap, and it stays gentle (one tiny command per interval) so
11
+ it looks like normal use, not abuse.
12
+
13
+ Honesty note for users: aggressive keep-alive or holding a GPU runtime idle just
14
+ to keep it can get a Google account flagged. Keep the interval reasonable.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from typing import Callable, Optional
22
+
23
+ RunRemote = Callable[[str], str]
24
+
25
+ # Minimal, cheap Python run on the VM (via `colab exec`) to confirm the kernel is
26
+ # still reachable. `colab exec` executes Python, not shell, so this is Python.
27
+ _PING = "import time; open('/tmp/.colabapi_alive','w').write(str(time.time())); print('ok')"
28
+
29
+
30
+ class KeepAlive:
31
+ def __init__(self, run_remote: RunRemote, interval: int = 60,
32
+ on_error: Optional[Callable[[Exception], None]] = None):
33
+ self.run_remote = run_remote
34
+ self.interval = max(interval, 30)
35
+ self.on_error = on_error
36
+ self._stop = threading.Event()
37
+ self._thread: Optional[threading.Thread] = None
38
+ self.last_ok: Optional[float] = None
39
+ self.failures = 0
40
+
41
+ def _loop(self) -> None:
42
+ while not self._stop.is_set():
43
+ try:
44
+ out = self.run_remote(_PING)
45
+ if "ok" in out:
46
+ self.last_ok = time.time()
47
+ self.failures = 0
48
+ except Exception as exc: # noqa: BLE001 - reported via callback
49
+ self.failures += 1
50
+ if self.on_error:
51
+ self.on_error(exc)
52
+ self._stop.wait(self.interval)
53
+
54
+ def start(self) -> None:
55
+ if self._thread and self._thread.is_alive():
56
+ return
57
+ self._stop.clear()
58
+ self._thread = threading.Thread(target=self._loop, name="colabapi-keepalive", daemon=True)
59
+ self._thread.start()
60
+
61
+ def stop(self) -> None:
62
+ self._stop.set()
63
+ if self._thread:
64
+ self._thread.join(timeout=2)
colabapi/monitor.py ADDED
@@ -0,0 +1,145 @@
1
+ """Live CPU / RAM / GPU monitor for a connected Colab runtime.
2
+
3
+ Stats are read from *inside* the runtime by executing a small Python snippet over
4
+ the same tunnel used for the shell (via `colab exec`, which runs Python), so the
5
+ monitor reflects the Colab VM, not your local machine. Rendering is decoupled
6
+ from transport: pass any callable that runs Python on the runtime and returns its
7
+ stdout.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import Callable
14
+
15
+ from rich.console import Group
16
+ from rich.live import Live
17
+ from rich.panel import Panel
18
+ from rich.progress_bar import ProgressBar
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ RunRemote = Callable[[str], str]
23
+
24
+ # One Python program (run on the VM via `colab exec`) that emits CPU%, memory
25
+ # (MiB), and one "GPU <csv>" line per GPU. CPU/RAM come from psutil (with a
26
+ # /proc/meminfo fallback); GPU comes from nvidia-smi via subprocess. No GPU lines
27
+ # simply means a CPU-only runtime.
28
+ _STATS_SNIPPET = r'''
29
+ import subprocess
30
+ try:
31
+ import psutil
32
+ c = psutil.cpu_percent(interval=0.3)
33
+ m = psutil.virtual_memory()
34
+ print("CPU %.1f" % c)
35
+ print("MEM %d %d" % (m.used // 1048576, m.total // 1048576))
36
+ except Exception:
37
+ try:
38
+ d = {}
39
+ with open("/proc/meminfo") as f:
40
+ for line in f:
41
+ p = line.split()
42
+ d[p[0].rstrip(":")] = int(p[1])
43
+ total = d["MemTotal"] // 1024
44
+ avail = d.get("MemAvailable", d.get("MemFree", 0)) // 1024
45
+ print("CPU 0.0")
46
+ print("MEM %d %d" % (total - avail, total))
47
+ except Exception:
48
+ print("CPU 0.0")
49
+ print("MEM 0 0")
50
+ try:
51
+ out = subprocess.run(
52
+ ["nvidia-smi",
53
+ "--query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu",
54
+ "--format=csv,noheader,nounits"],
55
+ capture_output=True, text=True, timeout=5).stdout
56
+ for line in out.strip().splitlines():
57
+ if line.strip():
58
+ print("GPU " + line)
59
+ except Exception:
60
+ pass
61
+ '''
62
+
63
+
64
+ def _bar(used: float, total: float, label: str, suffix: str) -> Table:
65
+ pct = (used / total * 100) if total else 0.0
66
+ grid = Table.grid(expand=True)
67
+ grid.add_column(width=12)
68
+ grid.add_column(ratio=1)
69
+ grid.add_column(width=22, justify="right")
70
+ grid.add_row(
71
+ Text(label, style="bold cyan"),
72
+ ProgressBar(total=100, completed=min(pct, 100), width=None),
73
+ Text(suffix, style="dim"),
74
+ )
75
+ return grid
76
+
77
+
78
+ def _parse_cpu_mem(out: str) -> tuple[float, float, float]:
79
+ cpu = used = total = 0.0
80
+ for line in out.splitlines():
81
+ parts = line.split()
82
+ if parts and parts[0] == "CPU":
83
+ cpu = float(parts[1])
84
+ elif parts and parts[0] == "MEM":
85
+ used, total = float(parts[1]), float(parts[2])
86
+ return cpu, used, total
87
+
88
+
89
+ def _parse_gpu(out: str) -> list[dict]:
90
+ gpus = []
91
+ for line in out.splitlines():
92
+ if not line.startswith("GPU "):
93
+ continue
94
+ cells = [c.strip() for c in line[4:].split(",")]
95
+ if len(cells) >= 5:
96
+ gpus.append(
97
+ {
98
+ "name": cells[0],
99
+ "util": float(cells[1] or 0),
100
+ "mem_used": float(cells[2] or 0),
101
+ "mem_total": float(cells[3] or 0),
102
+ "temp": float(cells[4] or 0),
103
+ }
104
+ )
105
+ return gpus
106
+
107
+
108
+ def build_panel(run_remote: RunRemote, session_line: str = "") -> Panel:
109
+ out = run_remote(_STATS_SNIPPET)
110
+ cpu, mem_used, mem_total = _parse_cpu_mem(out)
111
+ gpus = _parse_gpu(out)
112
+
113
+ rows = [
114
+ _bar(cpu, 100, "CPU", f"{cpu:.0f}%"),
115
+ _bar(mem_used, mem_total, "RAM", f"{mem_used/1024:.1f} / {mem_total/1024:.1f} GiB"),
116
+ ]
117
+ for i, g in enumerate(gpus):
118
+ rows.append(
119
+ _bar(g["util"], 100, f"GPU{i}", f"{g['util']:.0f}% {g['temp']:.0f}°C")
120
+ )
121
+ rows.append(
122
+ _bar(
123
+ g["mem_used"],
124
+ g["mem_total"],
125
+ " VRAM",
126
+ f"{g['mem_used']/1024:.1f} / {g['mem_total']/1024:.1f} GiB",
127
+ )
128
+ )
129
+ if not gpus:
130
+ rows.append(Text(" No GPU on this runtime (CPU-only).", style="dim"))
131
+
132
+ title = "colabapi runtime monitor"
133
+ subtitle = session_line or "Ctrl+C to exit monitor"
134
+ return Panel(Group(*rows), title=title, subtitle=subtitle, border_style="cyan")
135
+
136
+
137
+ def live_monitor(run_remote: RunRemote, session_line_fn: Callable[[], str], interval: float = 2.0) -> None:
138
+ """Render the monitor until interrupted with Ctrl+C."""
139
+ with Live(refresh_per_second=4, screen=False) as live:
140
+ try:
141
+ while True:
142
+ live.update(build_panel(run_remote, session_line_fn()))
143
+ time.sleep(interval)
144
+ except KeyboardInterrupt:
145
+ pass
colabapi/runtime.py ADDED
@@ -0,0 +1,85 @@
1
+ """Colab runtime (accelerator) catalog + mapping to `colab new` flags.
2
+
3
+ Google exposes no API that says which runtimes an account may allocate, so this
4
+ is a static catalog of the options the official CLI accepts, annotated with the
5
+ tier each typically requires. colabapi shows the menu and flags paid tiers; the
6
+ browser/Google is the source of truth for what actually gets allocated (a free
7
+ account asking for an A100 will simply be refused by Colab).
8
+
9
+ `colab_flags` is the argument list handed to `colab new`, kept here so the whole
10
+ accelerator mapping lives in one place. Validated against google-colab-cli as
11
+ documented mid-2026 (GPUs: T4, L4, G4, H100, A100; TPUs: v5e1, v6e1).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RuntimeType:
21
+ key: str
22
+ label: str
23
+ accelerator: str
24
+ tier: str # "free", "pro", "pro+", "paid"
25
+ notes: str
26
+ colab_flags: tuple[str, ...] = field(default_factory=tuple)
27
+
28
+ @property
29
+ def free(self) -> bool:
30
+ return self.tier == "free"
31
+
32
+
33
+ # Ordered most-accessible first.
34
+ RUNTIMES: list[RuntimeType] = [
35
+ RuntimeType(
36
+ key="cpu", label="CPU", accelerator="None", tier="free",
37
+ notes="Always available. Good for lightweight demos and I/O work.",
38
+ colab_flags=(),
39
+ ),
40
+ RuntimeType(
41
+ key="t4", label="T4 GPU", accelerator="NVIDIA T4 (16 GB)", tier="free",
42
+ notes="Baseline free GPU. Rate-limited; availability varies.",
43
+ colab_flags=("--gpu", "T4"),
44
+ ),
45
+ RuntimeType(
46
+ key="l4", label="L4 GPU", accelerator="NVIDIA L4 (24 GB)", tier="pro",
47
+ notes="Usually Pro/Pay-As-You-Go; occasionally free.",
48
+ colab_flags=("--gpu", "L4"),
49
+ ),
50
+ RuntimeType(
51
+ key="g4", label="G4 GPU", accelerator="RTX PRO 6000 Blackwell (~96 GB)", tier="pro+",
52
+ notes="High-VRAM Blackwell option; Pro+/PAYG.",
53
+ colab_flags=("--gpu", "G4"),
54
+ ),
55
+ RuntimeType(
56
+ key="a100", label="A100 GPU", accelerator="NVIDIA A100 (40 GB)", tier="pro+",
57
+ notes="Pro+/PAYG; limited availability; high compute-unit burn.",
58
+ colab_flags=("--gpu", "A100"),
59
+ ),
60
+ RuntimeType(
61
+ key="h100", label="H100 GPU", accelerator="NVIDIA H100 (80 GB)", tier="pro+",
62
+ notes="Newest, highest compute-unit burn rate; Pro+/PAYG.",
63
+ colab_flags=("--gpu", "H100"),
64
+ ),
65
+ RuntimeType(
66
+ key="tpu-v5e", label="TPU v5e", accelerator="Cloud TPU v5e-1", tier="paid",
67
+ notes="Paid users; ~197 TFLOPs / 48 GB per core config.",
68
+ colab_flags=("--tpu", "v5e1"),
69
+ ),
70
+ RuntimeType(
71
+ key="tpu-v6e", label="TPU v6e", accelerator="Cloud TPU v6e-1", tier="paid",
72
+ notes="Newest TPU; paid users.",
73
+ colab_flags=("--tpu", "v6e1"),
74
+ ),
75
+ ]
76
+
77
+ RUNTIME_BY_KEY = {r.key: r for r in RUNTIMES}
78
+
79
+
80
+ def get(key: str) -> RuntimeType | None:
81
+ return RUNTIME_BY_KEY.get(key)
82
+
83
+
84
+ def available_for_free() -> list[RuntimeType]:
85
+ return [r for r in RUNTIMES if r.free]
colabapi/service.py ADDED
@@ -0,0 +1,73 @@
1
+ """systemd user-service integration.
2
+
3
+ `colabapi service install` drops a user-level systemd unit that runs the
4
+ keep-alive/tunnel daemon so the session survives after you log out of the VPS.
5
+ User-level (systemctl --user) is preferred: it needs no root and keeps colabapi
6
+ scoped to the invoking user. A --system flag is offered for headless servers
7
+ where lingering user services are undesirable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ UNIT_NAME = "colabapi.service"
18
+ USER_UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
19
+
20
+ _UNIT_TEMPLATE = """\
21
+ [Unit]
22
+ Description=colabapi: persistent Google Colab runtime tunnel + keep-alive
23
+ Documentation=https://github.com/lil-limbo/colabapi
24
+ After=network-online.target
25
+ Wants=network-online.target
26
+
27
+ [Service]
28
+ Type=simple
29
+ ExecStart={exec_path} daemon --foreground
30
+ Restart=on-failure
31
+ RestartSec=5
32
+
33
+ [Install]
34
+ WantedBy=default.target
35
+ """
36
+
37
+
38
+ def _colabapi_path() -> str:
39
+ return shutil.which("colabapi") or str(Path.home() / ".local" / "bin" / "colabapi")
40
+
41
+
42
+ def _systemctl(*args: str) -> subprocess.CompletedProcess:
43
+ return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True)
44
+
45
+
46
+ def install(enable: bool = True, start: bool = False) -> str:
47
+ USER_UNIT_DIR.mkdir(parents=True, exist_ok=True)
48
+ unit_path = USER_UNIT_DIR / UNIT_NAME
49
+ unit_path.write_text(_UNIT_TEMPLATE.format(exec_path=_colabapi_path()))
50
+ _systemctl("daemon-reload")
51
+ if enable:
52
+ _systemctl("enable", UNIT_NAME)
53
+ if start:
54
+ _systemctl("start", UNIT_NAME)
55
+ # Enable lingering so the user service runs without an active login session.
56
+ subprocess.run(["loginctl", "enable-linger", os.environ.get("USER", "")],
57
+ capture_output=True, text=True)
58
+ return str(unit_path)
59
+
60
+
61
+ def uninstall() -> None:
62
+ _systemctl("disable", "--now", UNIT_NAME)
63
+ unit_path = USER_UNIT_DIR / UNIT_NAME
64
+ unit_path.unlink(missing_ok=True)
65
+ _systemctl("daemon-reload")
66
+
67
+
68
+ def status() -> str:
69
+ return _systemctl("status", UNIT_NAME).stdout or "colabapi service not installed"
70
+
71
+
72
+ def is_installed() -> bool:
73
+ return (USER_UNIT_DIR / UNIT_NAME).exists()
colabapi/shellview.py ADDED
@@ -0,0 +1,108 @@
1
+ """Open an interactive Colab shell with a live resource monitor above it.
2
+
3
+ When tmux is available locally we build a split window: a small top pane runs the
4
+ colabapi monitor for the chosen session, and the bottom pane runs the real
5
+ interactive shell (`colab console -s <name>`). The session name is shown in the
6
+ tmux status bar. When the shell exits, the whole layout tears down.
7
+
8
+ If tmux is not installed we fall back to printing a one-shot monitor snapshot,
9
+ then handing the terminal straight to `colab console`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+
20
+ from rich.console import Console
21
+
22
+ from .colabcli import ColabCLI
23
+
24
+ console = Console()
25
+
26
+ # Height (rows) of the monitor pane on top.
27
+ _MONITOR_ROWS = 11
28
+
29
+
30
+ def _child_env(colab: ColabCLI) -> dict:
31
+ return colab._child_env()
32
+
33
+
34
+ def open_shell(colab: ColabCLI, name: str) -> int:
35
+ """Open the shell for session `name`, with a live monitor on top if possible."""
36
+ colab.session = name
37
+ if shutil.which("tmux"):
38
+ return _open_tmux(colab, name)
39
+ return _open_plain(colab, name)
40
+
41
+
42
+ def _open_tmux(colab: ColabCLI, name: str) -> int:
43
+ exe = colab.require()
44
+ py = sys.executable or "python3"
45
+ tmux_ses = f"colabapi_{name}_{os.getpid()}"
46
+
47
+ # Env is embedded directly in each pane command, because tmux panes do not
48
+ # reliably inherit the environment of the client that creates the session.
49
+ env_prefix = (
50
+ f"COLABAPI_COLAB_BIN={shlex.quote(exe)} "
51
+ f"OAUTHLIB_RELAX_TOKEN_SCOPE=1 "
52
+ )
53
+ monitor_cmd = env_prefix + f"{shlex.quote(py)} -m colabapi.cli _panemonitor {shlex.quote(name)}"
54
+ # When the shell exits, kill the whole tmux session so `attach` returns and we
55
+ # clean up (otherwise the monitor pane would keep the session alive).
56
+ console_cmd = (
57
+ env_prefix
58
+ + f"{shlex.quote(exe)} console -s {shlex.quote(name)}; "
59
+ + f"tmux kill-session -t {shlex.quote(tmux_ses)} 2>/dev/null"
60
+ )
61
+
62
+ env = _child_env(colab)
63
+
64
+ def tmux(*args: str) -> subprocess.CompletedProcess:
65
+ return subprocess.run(["tmux", *args], env=env,
66
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
67
+
68
+ # Top pane: the live monitor. Window name = session name.
69
+ tmux("new-session", "-d", "-s", tmux_ses, "-n", name, monitor_cmd)
70
+ # Bottom pane: the interactive shell.
71
+ tmux("split-window", "-v", "-t", tmux_ses, console_cmd)
72
+ # Keep the monitor pane small and focus the shell.
73
+ tmux("resize-pane", "-t", f"{tmux_ses}.0", "-y", str(_MONITOR_ROWS))
74
+ tmux("select-pane", "-t", f"{tmux_ses}.1")
75
+ # A distinct prefix (Ctrl-a) avoids clashing with the remote tmux's Ctrl-b.
76
+ tmux("set-option", "-t", tmux_ses, "prefix", "C-a")
77
+ tmux("set-option", "-t", tmux_ses, "status", "on")
78
+ tmux("set-option", "-t", tmux_ses, "status-style", "bg=colour24,fg=white")
79
+ tmux("set-option", "-t", tmux_ses, "status-left", f" colabapi ❯ {name} ")
80
+ tmux("set-option", "-t", tmux_ses, "status-left-length", "48")
81
+ tmux("set-option", "-t", tmux_ses, "status-right", " Ctrl-a d to detach ")
82
+
83
+ console.print(f"[green]Opening[/] [cyan]{name}[/]: monitor on top, shell below. "
84
+ "[dim](tmux prefix is Ctrl-a; type 'exit' to close)[/]")
85
+ code = subprocess.run(["tmux", "attach-session", "-t", tmux_ses], env=env).returncode
86
+ tmux("kill-session", "-t", tmux_ses) # no-op if already gone
87
+ console.print(f"[dim]Shell closed. Session '{name}' is still running "
88
+ "(use `colabapi stop` to end it).[/]")
89
+ return code
90
+
91
+
92
+ def _open_plain(colab: ColabCLI, name: str) -> int:
93
+ """Fallback without tmux: print a monitor snapshot, then hand over the shell."""
94
+ from . import monitor as monitor_mod
95
+
96
+ def run_remote(code: str) -> str:
97
+ res = colab.exec_code(code)
98
+ return res.stdout if res.stdout.strip() else res.stderr
99
+
100
+ console.print("[dim]tmux not found; showing a one-shot monitor snapshot, then the shell.[/]")
101
+ try:
102
+ console.print(monitor_mod.build_panel(run_remote, f"session {name}"))
103
+ except Exception as exc: # noqa: BLE001
104
+ console.print(f"[dim](could not read monitor: {exc})[/]")
105
+ console.print(f"[green]Terminal into[/] [cyan]{name}[/]. Type 'exit' or Ctrl-D to return.\n")
106
+ code = colab.console()
107
+ console.print(f"\n[dim]Shell closed. Session '{name}' is still running.[/]")
108
+ return code
colabapi/timing.py ADDED
@@ -0,0 +1,53 @@
1
+ """Session-timing helpers.
2
+
3
+ Colab enforces two independent limits:
4
+ * an ABSOLUTE max lifetime (roughly 12h free, longer on paid tiers) that
5
+ nothing can extend, and
6
+ * an IDLE timeout that disconnects a runtime doing nothing. This is the one
7
+ the keep-alive addresses.
8
+
9
+ colabapi can only *estimate* the absolute end because Google does not publish an
10
+ exact per-session deadline. The estimate is based on when the runtime came up
11
+ plus the tier's typical cap, and is clearly labeled as an estimate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import Optional
18
+
19
+
20
+ def human_duration(seconds: float) -> str:
21
+ seconds = int(max(seconds, 0))
22
+ h, rem = divmod(seconds, 3600)
23
+ m, s = divmod(rem, 60)
24
+ if h:
25
+ return f"{h}h {m:02d}m"
26
+ if m:
27
+ return f"{m}m {s:02d}s"
28
+ return f"{s}s"
29
+
30
+
31
+ def estimated_end(started_at: Optional[float], max_lifetime_hours: float) -> Optional[float]:
32
+ if not started_at:
33
+ return None
34
+ return started_at + max_lifetime_hours * 3600
35
+
36
+
37
+ def remaining(started_at: Optional[float], max_lifetime_hours: float) -> Optional[float]:
38
+ end = estimated_end(started_at, max_lifetime_hours)
39
+ if end is None:
40
+ return None
41
+ return end - time.time()
42
+
43
+
44
+ def session_line(started_at: Optional[float], max_lifetime_hours: float) -> str:
45
+ if not started_at:
46
+ return "runtime not started"
47
+ up = time.time() - started_at
48
+ rem = remaining(started_at, max_lifetime_hours)
49
+ if rem is None:
50
+ return f"up {human_duration(up)}"
51
+ if rem <= 0:
52
+ return f"up {human_duration(up)}, past estimated limit (may disconnect any moment)"
53
+ return f"up {human_duration(up)} · ~{human_duration(rem)} left (est.)"