ttsd-colabcli 1.0.0

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.
Files changed (47) hide show
  1. package/cli.js +148 -0
  2. package/core/app/__init__.py +0 -0
  3. package/core/app/colab_cli/__init__.py +0 -0
  4. package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  5. package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
  6. package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
  7. package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
  8. package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
  9. package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
  10. package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
  11. package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
  12. package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
  13. package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
  14. package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
  15. package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
  16. package/core/app/colab_cli/auth.py +278 -0
  17. package/core/app/colab_cli/auto_update.py +248 -0
  18. package/core/app/colab_cli/cli.py +155 -0
  19. package/core/app/colab_cli/client.py +310 -0
  20. package/core/app/colab_cli/commands/__init__.py +14 -0
  21. package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
  23. package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
  24. package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
  25. package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
  26. package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
  27. package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
  28. package/core/app/colab_cli/commands/automation.py +265 -0
  29. package/core/app/colab_cli/commands/execution.py +362 -0
  30. package/core/app/colab_cli/commands/files.py +204 -0
  31. package/core/app/colab_cli/commands/run.py +477 -0
  32. package/core/app/colab_cli/commands/session.py +519 -0
  33. package/core/app/colab_cli/commands/utility.py +436 -0
  34. package/core/app/colab_cli/common.py +185 -0
  35. package/core/app/colab_cli/console.py +172 -0
  36. package/core/app/colab_cli/contents.py +93 -0
  37. package/core/app/colab_cli/converter.py +184 -0
  38. package/core/app/colab_cli/history.py +65 -0
  39. package/core/app/colab_cli/oauth_config.json +11 -0
  40. package/core/app/colab_cli/repl.py +173 -0
  41. package/core/app/colab_cli/runtime.py +262 -0
  42. package/core/app/colab_cli/state.py +156 -0
  43. package/core/app/colab_cli/utils.py +85 -0
  44. package/core/colab/worker.py +679 -0
  45. package/core/daemon.py +184 -0
  46. package/core/requirements.txt +8 -0
  47. package/package.json +22 -0
@@ -0,0 +1,477 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ `colab run <script.py> [args...]` — shebang-friendly one-shot execution.
17
+
18
+ Combines `colab new` + `colab exec` + `colab stop` into a single fire-and-forget
19
+ invocation. The Python script's body runs in a freshly-allocated Colab kernel
20
+ with `sys.argv` set as if it had been invoked via `python script.py [args...]`,
21
+ and the VM is automatically released when the script finishes (unless `--keep`
22
+ is passed).
23
+
24
+ Designed to support shebangs:
25
+
26
+ #!/usr/bin/env -S colab run --gpu T4
27
+ import torch
28
+ print(torch.cuda.get_device_name(0))
29
+
30
+ See docs/05_run_command.md for the full design.
31
+ """
32
+
33
+ import datetime
34
+ import os
35
+ import uuid
36
+ from typing import List, Optional
37
+
38
+ import typer
39
+ from typing_extensions import Annotated
40
+
41
+ from app.colab_cli.client import (
42
+ Accelerator,
43
+ ColabRequestError,
44
+ PostAssignmentResponse,
45
+ Variant,
46
+ )
47
+ from app.colab_cli.commands.session import (
48
+ _is_scope_error,
49
+ _scope_remediation_message,
50
+ spawn_keep_alive,
51
+ )
52
+ from app.colab_cli.runtime import ColabRuntime
53
+ from app.colab_cli.state import SessionState
54
+ from app.colab_cli.utils import get_status_code, is_terminal_error
55
+
56
+
57
+ # TODO(sethtroisi): dedupe this logic with similar in session.py
58
+ def _resolve_accelerator(gpu: Optional[str], tpu: Optional[str]):
59
+ """Mirror the mapping logic in `commands.session.new`. Centralised so the
60
+ two commands stay in lock-step on supported accelerator names.
61
+ """
62
+ if tpu:
63
+ variant = Variant.TPU
64
+ accelerator = Accelerator.V5E1 if tpu.lower() == "v5e1" else Accelerator.V6E1
65
+ return variant, accelerator
66
+ if gpu:
67
+ mapping = {
68
+ "a100": Accelerator.A100,
69
+ "h100": Accelerator.H100,
70
+ "l4": Accelerator.L4,
71
+ "t4": Accelerator.T4,
72
+ "g4": Accelerator.G4,
73
+ }
74
+ return Variant.GPU, mapping.get(gpu.lower(), Accelerator.A100)
75
+ return Variant.DEFAULT, Accelerator.NONE
76
+
77
+
78
+ def _build_script_payload(script_path: str, script_args: List[str]) -> str:
79
+ """Wrap the script body so it executes with native-`python`-like semantics.
80
+
81
+ Specifically:
82
+ - `sys.argv = [<basename>, *script_args]` so `argparse` etc. work.
83
+ - `__name__ = '__main__'` so `if __name__ == "__main__":` guards fire.
84
+ - Suppress the IPython UserWarning "To exit: use 'exit', 'quit', or
85
+ Ctrl-D." which fires whenever the script calls `sys.exit(...)`. This
86
+ warning is meaningful in an interactive REPL, but for `colab run` it
87
+ is pure noise that doesn't appear when running `python script.py`.
88
+
89
+ The script body is appended verbatim; the prelude is short so any
90
+ traceback line numbers from user code remain close to the original.
91
+ """
92
+ basename = os.path.basename(script_path)
93
+ with open(script_path, "r", encoding="utf-8") as f:
94
+ body = f.read()
95
+
96
+ # `repr()` produces a safe, round-trippable Python literal for arbitrary
97
+ # strings (handles quotes, backslashes, non-ASCII).
98
+ argv_literal = f"[{', '.join(repr(x) for x in [basename] + script_args)}]"
99
+
100
+ return (
101
+ "import sys, warnings\n"
102
+ f"sys.argv = {argv_literal}\n"
103
+ "__name__ = '__main__'\n"
104
+ "warnings.filterwarnings('ignore', message=\"To exit: use\")\n"
105
+ + _strip_shebang(body)
106
+ )
107
+
108
+
109
+ def _strip_shebang(body: str) -> str:
110
+ """Remove a leading `#!...\\n` if present. The remote kernel doesn't need
111
+ or understand it (it's a contract between the local kernel and the file's
112
+ executable bit), and leaving it in just adds noise.
113
+ """
114
+ if body.startswith("#!"):
115
+ nl = body.find("\n")
116
+ return body[nl + 1 :] if nl != -1 else ""
117
+ return body
118
+
119
+
120
+ def _is_systemexit(out) -> bool:
121
+ """True iff this output is a `raise SystemExit(...)` (a.k.a. `sys.exit`)."""
122
+ return out.get("output_type") == "error" and out.get("ename") == "SystemExit"
123
+
124
+
125
+ def _systemexit_code(out) -> int:
126
+ """Map a SystemExit kernel output back to a CPython-style integer exit code.
127
+
128
+ CPython conventions (mirrored):
129
+ - `sys.exit()` / `sys.exit(None)` / `sys.exit(0)` -> 0
130
+ - `sys.exit(<int>)` -> <int>
131
+ - `sys.exit('msg')` (any non-int) -> 1
132
+ """
133
+ evalue = (out.get("evalue") or "").strip()
134
+ if evalue in ("", "None", "0"):
135
+ return 0
136
+ try:
137
+ return int(evalue)
138
+ except ValueError:
139
+ return 1
140
+
141
+
142
+ def _exit_code_from_outputs(outputs) -> int:
143
+ """Derive the CLI's exit code from the kernel's outputs for a single cell.
144
+
145
+ A `SystemExit` is treated like CPython would treat the same call from a
146
+ plain `python script.py` invocation. Any *other* error (uncaught
147
+ exception, NameError, etc.) is exit 1.
148
+ """
149
+ code = 0
150
+ for o in outputs:
151
+ if o.get("output_type") != "error":
152
+ continue
153
+ if _is_systemexit(o):
154
+ ec = _systemexit_code(o)
155
+ # Last SystemExit wins, matching the runtime — and any non-zero
156
+ # eclipses any prior zero.
157
+ code = ec if ec != 0 else code
158
+ else:
159
+ return 1
160
+ return code
161
+
162
+
163
+ def _make_run_output_hook(output_image=None):
164
+ """Build an `output_hook` for `runtime.execute_code` that:
165
+ - Routes normal output to `display_output` (stream/image/error).
166
+ - Suppresses the `SystemExit` traceback so `sys.exit(0)` is silent (it
167
+ wouldn't print anything under `python script.py` either) and
168
+ `sys.exit(N)` doesn't dump a noisy IPython-styled traceback when the
169
+ intent is "shell exit code N".
170
+
171
+ The kernel still RETURNS the SystemExit output to us (so we can derive the
172
+ exit code in `_exit_code_from_outputs`); we just don't render it.
173
+ """
174
+ # Imported here to avoid a circular import via execution.py at module load.
175
+ from app.colab_cli.commands.execution import display_output
176
+
177
+ def hook(out):
178
+ if _is_systemexit(out):
179
+ return
180
+ display_output(out, output_image)
181
+
182
+ return hook
183
+
184
+
185
+ def run_command(
186
+ ctx: typer.Context,
187
+ script: Annotated[
188
+ str,
189
+ typer.Argument(
190
+ help="Path to a local Python file to execute on a fresh Colab VM."
191
+ ),
192
+ ],
193
+ script_args: Annotated[
194
+ Optional[List[str]],
195
+ typer.Argument(
196
+ help=(
197
+ "Arguments forwarded to the script as sys.argv[1:]. "
198
+ "Anything after the script path is passed through verbatim."
199
+ ),
200
+ ),
201
+ ] = None,
202
+ session: Annotated[
203
+ Optional[str],
204
+ typer.Option(
205
+ "-s",
206
+ "--session",
207
+ help=(
208
+ "Name for the ephemeral session (auto-generated if omitted). "
209
+ "Useful with --keep so you can attach later via `colab exec -s <name>`."
210
+ ),
211
+ ),
212
+ ] = None,
213
+ tpu: Annotated[
214
+ Optional[str],
215
+ typer.Option(help="TPU accelerator variant. Supported: v5e1, v6e1."),
216
+ ] = None,
217
+ gpu: Annotated[
218
+ Optional[str],
219
+ typer.Option(
220
+ help=(
221
+ "GPU accelerator variant. Supported: T4, L4, G4, H100, A100. "
222
+ "If omitted (along with --tpu), a CPU runtime is created."
223
+ ),
224
+ ),
225
+ ] = None,
226
+ keep: Annotated[
227
+ bool,
228
+ typer.Option(
229
+ "--keep",
230
+ help=(
231
+ "Do not stop the session after the script finishes. The session "
232
+ "remains in `colab sessions` until you run `colab stop`."
233
+ ),
234
+ ),
235
+ ] = False,
236
+ timeout: Annotated[
237
+ Optional[float],
238
+ typer.Option("--timeout", help="Timeout in seconds for code execution"),
239
+ ] = 30.0,
240
+ ):
241
+ """Run a Python script on a fresh Colab VM, then release the VM
242
+
243
+ Designed to be used as a shebang interpreter, e.g.
244
+
245
+ #!/usr/bin/env -S colab run --gpu T4
246
+
247
+ so a single executable .py file can rent a GPU, run, and clean up after
248
+ itself.
249
+ """
250
+ from app.colab_cli.common import state
251
+
252
+ script_args = script_args or []
253
+
254
+ # AGENTS.md item 10: validate locally BEFORE allocating a VM. A typo'd
255
+ # script path should not cost the user real compute.
256
+ if not os.path.isfile(script):
257
+ typer.echo(f"[colab] Script not found: {script}", err=True)
258
+ raise typer.Exit(2)
259
+
260
+ name = session or f"run-{uuid.uuid4().hex[:6]}"
261
+ variant, accelerator = _resolve_accelerator(gpu, tpu)
262
+
263
+ typer.echo(f"[colab] Creating session '{name}'...", err=True)
264
+ try:
265
+ res = state.client.assign(
266
+ uuid.uuid4(), variant=variant, accelerator=accelerator
267
+ )
268
+ except ColabRequestError as e:
269
+ # Mirror `colab new`'s friendly accelerator-quota message.
270
+ if get_status_code(e) == 400 and accelerator != Accelerator.NONE:
271
+ typer.echo(
272
+ f"[colab] Backend rejected accelerator '{accelerator.value}'. "
273
+ "You may not have quota or entitlement for this accelerator on "
274
+ "your account. Try a different one (e.g. --gpu T4) or omit "
275
+ "--gpu/--tpu for a CPU runtime.",
276
+ err=True,
277
+ )
278
+ raise typer.Exit(code=1)
279
+ raise
280
+
281
+ if isinstance(res, PostAssignmentResponse):
282
+ token = res.runtime_proxy_info.token
283
+ url = res.runtime_proxy_info.url
284
+ endpoint = res.endpoint
285
+ else:
286
+ token = (
287
+ res.runtime_proxy_info.token
288
+ if hasattr(res, "runtime_proxy_info")
289
+ else getattr(res, "runtime_proxy_token", "")
290
+ )
291
+ url = res.runtime_proxy_info.url if hasattr(res, "runtime_proxy_info") else ""
292
+ endpoint = res.endpoint
293
+
294
+ s = SessionState(
295
+ name=name,
296
+ token=token,
297
+ url=url,
298
+ endpoint=endpoint,
299
+ variant=variant.value,
300
+ accelerator=accelerator.value,
301
+ )
302
+
303
+ # Pre-flight keep-alive: same scope-detection dance as `colab new` so a
304
+ # missing OAuth scope doesn't leak a billable assignment.
305
+ try:
306
+ state.client.keep_alive_assignment(endpoint)
307
+ except ColabRequestError as e:
308
+ if get_status_code(e) == 403 and _is_scope_error(e):
309
+ typer.echo(
310
+ "[colab] Keep-alive pre-flight failed: your OAuth "
311
+ "credentials are missing the 'colaboratory' scope, which "
312
+ "is required by the Colab RuntimeService.\n",
313
+ err=True,
314
+ )
315
+ typer.echo(_scope_remediation_message(state.auth_provider), err=True)
316
+ try:
317
+ state.client.unassign(endpoint)
318
+ except Exception:
319
+ pass
320
+ raise typer.Exit(code=1)
321
+ # Other failures: don't block — the daemon will retry.
322
+
323
+ # AGENTS.md item 17: persist BEFORE spawning the daemon so the daemon's
324
+ # initial state.store.get(name) doesn't race the parent.
325
+ state.store.add(s)
326
+ s.keep_alive_pid = spawn_keep_alive(
327
+ endpoint,
328
+ name,
329
+ auth_provider=state.auth_provider,
330
+ config_path=state.config_path,
331
+ )
332
+ state.store.add(s)
333
+ state.history.log_event(
334
+ name,
335
+ "session_created",
336
+ {
337
+ "endpoint": endpoint,
338
+ "variant": variant.value,
339
+ "accelerator": accelerator.value,
340
+ "via": "run",
341
+ },
342
+ )
343
+ typer.echo(f"[colab] Session READY ({name}). Executing {script}...", err=True)
344
+
345
+ # ----- Execute the script -------------------------------------------------
346
+ exit_code = 0
347
+ cleanup_reason = "run_completed"
348
+
349
+ def on_started(kid):
350
+ s.kernel_id = kid
351
+ state.store.add(s)
352
+
353
+ def on_sess_started(sid):
354
+ s.session_id = sid
355
+ state.store.add(s)
356
+
357
+ runtime = ColabRuntime(
358
+ s.url,
359
+ s.token,
360
+ kernel_id=s.kernel_id,
361
+ session_id=s.session_id,
362
+ on_kernel_started=on_started,
363
+ on_session_started=on_sess_started,
364
+ )
365
+
366
+ try:
367
+ # Same /content prelude as `colab exec` for consistency.
368
+ try:
369
+ runtime.execute_code(
370
+ "import os; os.makedirs('/content', exist_ok=True); "
371
+ "os.chdir('/content')"
372
+ )
373
+ except Exception as e:
374
+ if is_terminal_error(e):
375
+ typer.echo(
376
+ f"[colab] Session '{name}' appears to be lost (404/401).",
377
+ err=True,
378
+ )
379
+ state.prune_session(name)
380
+ raise typer.Exit(1)
381
+ raise
382
+
383
+ payload = _build_script_payload(script, script_args)
384
+ s.running = f"run({os.path.basename(script)})"
385
+ s.last_execution = (
386
+ script,
387
+ None,
388
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
389
+ )
390
+ state.store.add(s)
391
+
392
+ try:
393
+ outputs = runtime.execute_code(
394
+ payload, output_hook=_make_run_output_hook(), timeout=timeout
395
+ )
396
+ except Exception:
397
+ # Genuine transport-level failure. Cleanup still happens via the
398
+ # outer finally; surface non-zero exit so callers/CI notice.
399
+ exit_code = 1
400
+ cleanup_reason = "run_failed"
401
+ raise
402
+ else:
403
+ exit_code = _exit_code_from_outputs(outputs)
404
+ if exit_code != 0:
405
+ cleanup_reason = "run_failed"
406
+ state.history.log_event(
407
+ name,
408
+ "execution",
409
+ {"code": payload, "outputs": outputs, "via": "run"},
410
+ )
411
+ finally:
412
+ s.running = None
413
+ state.store.add(s)
414
+ # Best-effort runtime close (keeps remote kernel alive for --keep).
415
+ try:
416
+ runtime.stop()
417
+ except Exception:
418
+ pass
419
+
420
+ if not keep:
421
+ _teardown(name, s, reason=cleanup_reason)
422
+
423
+ if exit_code != 0:
424
+ raise typer.Exit(exit_code)
425
+
426
+
427
+ def _teardown(name: str, s: SessionState, *, reason: str) -> None:
428
+ """Best-effort full session teardown: kill the keep-alive daemon, ask the
429
+ remote kernel to shut down, unassign the VM, and remove local state.
430
+
431
+ Mirrors `commands.session.stop` but with a richer history event reason and
432
+ swallowing all errors (we don't want a teardown failure to mask the user's
433
+ exit code).
434
+ """
435
+ from app.colab_cli.common import kill_process, state
436
+
437
+ typer.echo(f"[colab] Stopping session '{name}'...", err=True)
438
+ if s.keep_alive_pid:
439
+ try:
440
+ kill_process(s.keep_alive_pid)
441
+ except Exception:
442
+ pass
443
+
444
+ try:
445
+ rt = ColabRuntime(s.url, s.token, kernel_id=s.kernel_id)
446
+ rt.stop(shutdown_kernel=True)
447
+ except Exception:
448
+ pass
449
+
450
+ try:
451
+ state.client.unassign(s.endpoint)
452
+ except Exception:
453
+ pass
454
+
455
+ try:
456
+ state.store.remove(name)
457
+ except Exception:
458
+ pass
459
+
460
+ try:
461
+ state.history.log_event(name, "session_terminated", {"reason": reason})
462
+ except Exception:
463
+ pass
464
+ typer.echo("[colab] Session terminated.", err=True)
465
+
466
+
467
+ def register(app: typer.Typer) -> None:
468
+ # `context_settings` lets unknown args after the script path flow through
469
+ # as positional `script_args` so users can pass `--flags-for-the-script`
470
+ # without Typer trying to consume them.
471
+ app.command(
472
+ name="run",
473
+ context_settings={
474
+ "allow_extra_args": True,
475
+ "ignore_unknown_options": True,
476
+ },
477
+ )(run_command)