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/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.)"
|