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.
- package/cli.js +148 -0
- package/core/app/__init__.py +0 -0
- package/core/app/colab_cli/__init__.py +0 -0
- package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
- package/core/app/colab_cli/auth.py +278 -0
- package/core/app/colab_cli/auto_update.py +248 -0
- package/core/app/colab_cli/cli.py +155 -0
- package/core/app/colab_cli/client.py +310 -0
- package/core/app/colab_cli/commands/__init__.py +14 -0
- package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/automation.py +265 -0
- package/core/app/colab_cli/commands/execution.py +362 -0
- package/core/app/colab_cli/commands/files.py +204 -0
- package/core/app/colab_cli/commands/run.py +477 -0
- package/core/app/colab_cli/commands/session.py +519 -0
- package/core/app/colab_cli/commands/utility.py +436 -0
- package/core/app/colab_cli/common.py +185 -0
- package/core/app/colab_cli/console.py +172 -0
- package/core/app/colab_cli/contents.py +93 -0
- package/core/app/colab_cli/converter.py +184 -0
- package/core/app/colab_cli/history.py +65 -0
- package/core/app/colab_cli/oauth_config.json +11 -0
- package/core/app/colab_cli/repl.py +173 -0
- package/core/app/colab_cli/runtime.py +262 -0
- package/core/app/colab_cli/state.py +156 -0
- package/core/app/colab_cli/utils.py +85 -0
- package/core/colab/worker.py +679 -0
- package/core/daemon.py +184 -0
- package/core/requirements.txt +8 -0
- 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)
|