main
py 666 lines 21.6 KB
Raw
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 """Tests for `colab run <script.py> [args...]` — shebang-friendly one-shot
16 execution that bundles `colab new` + `colab exec` + `colab stop`.
17 """
18
19 from unittest.mock import MagicMock
20
21 import pytest
22 from typer.testing import CliRunner
23
24 from colab_cli.cli import app
25 from colab_cli.client import (
26 Accelerator,
27 PostAssignmentResponse,
28 Variant,
29 )
30
31 runner = CliRunner()
32
33
34 @pytest.fixture
35 def mock_client(mock_common_state):
36 return mock_common_state.client
37
38
39 @pytest.fixture
40 def mock_store(mock_common_state):
41 return mock_common_state.store
42
43
44 @pytest.fixture
45 def mock_runtime_class(mocker):
46 """Patch ColabRuntime in the run module specifically."""
47 return mocker.patch("colab_cli.commands.run.ColabRuntime")
48
49
50 @pytest.fixture
51 def mock_spawn_keep_alive(mocker):
52 """Don't actually spawn a daemon during tests."""
53 return mocker.patch("colab_cli.commands.run.spawn_keep_alive", return_value=12345)
54
55
56 @pytest.fixture
57 def assign_response():
58 """A minimal PostAssignmentResponse-shaped mock for client.assign."""
59 res = MagicMock()
60 res.__class__ = PostAssignmentResponse
61 res.runtime_proxy_info.token = "tok"
62 res.runtime_proxy_info.url = "http://runtime"
63 res.endpoint = "ep-123"
64 return res
65
66
67 @pytest.fixture
68 def script_path(tmp_path):
69 p = tmp_path / "script.py"
70 p.write_text("print('hello from script')\n")
71 return p
72
73
74 # ---------------------------------------------------------------------------
75 # Happy path
76 # ---------------------------------------------------------------------------
77
78
79 def test_run_basic_flow(
80 mock_client,
81 mock_store,
82 mock_runtime_class,
83 mock_spawn_keep_alive,
84 assign_response,
85 script_path,
86 ):
87 """`colab run script.py` should: assign, exec, unassign."""
88 mock_client.assign.return_value = assign_response
89 mock_runtime = mock_runtime_class.return_value
90 mock_runtime.execute_code.return_value = []
91
92 # Simulate the persisted SessionState being readable by the run command.
93 persisted = {}
94
95 def store_add(s):
96 persisted["s"] = s
97
98 def store_get(name):
99 return persisted.get("s")
100
101 mock_store.add.side_effect = store_add
102 mock_store.get.side_effect = store_get
103
104 result = runner.invoke(app, ["run", str(script_path)])
105
106 assert result.exit_code == 0, result.output
107 # Allocation happened
108 mock_client.assign.assert_called_once()
109 # Script body was executed (the prelude + body is one execute_code call)
110 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
111 assert any("hello from script" in code for code in code_calls), (
112 f"Script body never sent to runtime. Calls: {code_calls}"
113 )
114 # Cleanup happened
115 mock_client.unassign.assert_called_once_with("ep-123")
116
117
118 def test_run_high_mem_passes_shape_to_assign(
119 mock_client,
120 mock_store,
121 mock_runtime_class,
122 mock_spawn_keep_alive,
123 assign_response,
124 script_path,
125 ):
126 mock_client.assign.return_value = assign_response
127 mock_runtime_class.return_value.execute_code.return_value = []
128
129 persisted = {}
130 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
131 mock_store.get.side_effect = lambda name: persisted.get("s")
132
133 result = runner.invoke(
134 app, ["run", "--gpu", "A100", "--high-mem", str(script_path)]
135 )
136 assert result.exit_code == 0, result.output
137
138 from colab_cli.client import Shape
139
140 _, kwargs = mock_client.assign.call_args
141 assert kwargs["shape"] == Shape.HIGH_RAM
142 assert persisted["s"].machine_shape == "HIGH_RAM"
143
144
145 # ---------------------------------------------------------------------------
146 # --keep flag
147 # ---------------------------------------------------------------------------
148
149
150 def test_run_keep_skips_unassign(
151 mock_client,
152 mock_store,
153 mock_runtime_class,
154 mock_spawn_keep_alive,
155 assign_response,
156 script_path,
157 ):
158 """With `--keep`, the session must NOT be unassigned after the script
159 finishes — the user wants to attach to it later."""
160 mock_client.assign.return_value = assign_response
161 mock_runtime = mock_runtime_class.return_value
162 mock_runtime.execute_code.return_value = []
163
164 persisted = {}
165 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
166 mock_store.get.side_effect = lambda name: persisted.get("s")
167
168 result = runner.invoke(app, ["run", "--keep", str(script_path)])
169
170 assert result.exit_code == 0, result.output
171 mock_client.assign.assert_called_once()
172 mock_client.unassign.assert_not_called()
173 mock_store.remove.assert_not_called()
174
175
176 # ---------------------------------------------------------------------------
177 # argv passthrough
178 # ---------------------------------------------------------------------------
179
180
181 def test_run_passes_argv(
182 mock_client,
183 mock_store,
184 mock_runtime_class,
185 mock_spawn_keep_alive,
186 assign_response,
187 script_path,
188 ):
189 """Args after the script must be exposed as `sys.argv` inside the kernel."""
190 mock_client.assign.return_value = assign_response
191 mock_runtime = mock_runtime_class.return_value
192 mock_runtime.execute_code.return_value = []
193
194 persisted = {}
195 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
196 mock_store.get.side_effect = lambda name: persisted.get("s")
197
198 result = runner.invoke(
199 app, ["run", str(script_path), "alpha", "beta", "--flag-for-script"]
200 )
201
202 assert result.exit_code == 0, result.output
203 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
204 # The execute_code call that contains the script body must also set
205 # sys.argv to mirror native python invocation.
206 body_calls = [c for c in code_calls if "hello from script" in c]
207 assert body_calls, f"Body never executed. Calls: {code_calls}"
208 body = body_calls[0]
209 assert "sys.argv" in body
210 assert "'script.py'" in body
211 assert "'alpha'" in body
212 assert "'beta'" in body
213 assert "'--flag-for-script'" in body
214
215
216 def test_run_env_flag_after_script_sets_env_and_preserves_argv(
217 mock_client,
218 mock_store,
219 mock_runtime_class,
220 mock_spawn_keep_alive,
221 assign_response,
222 script_path,
223 ):
224 """`--env KEY=VALUE` after the script path should configure the remote
225 environment, not get forwarded into sys.argv."""
226 mock_client.assign.return_value = assign_response
227 mock_runtime = mock_runtime_class.return_value
228 mock_runtime.execute_code.return_value = []
229
230 persisted = {}
231 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
232 mock_store.get.side_effect = lambda name: persisted.get("s")
233
234 result = runner.invoke(
235 app, ["run", str(script_path), "--env", "HF_TOKEN=abc", "alpha"]
236 )
237
238 assert result.exit_code == 0, result.output
239 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
240 body = next(c for c in code_calls if "hello from script" in c)
241 assert "import os\n" in body
242 assert "os.environ['HF_TOKEN'] = 'abc'" in body
243 assert body.index("os.environ['HF_TOKEN'] = 'abc'") < body.index(
244 "print('hello from script')"
245 )
246 assert "'alpha'" in body
247 assert "'--env'" not in body
248 assert "'HF_TOKEN=abc'" not in body
249
250
251 def test_run_env_flags_accumulate_and_split_on_first_equals(
252 mock_client,
253 mock_store,
254 mock_runtime_class,
255 mock_spawn_keep_alive,
256 assign_response,
257 script_path,
258 ):
259 """Repeated env flags should all become assignments; values may contain
260 additional '=' characters."""
261 mock_client.assign.return_value = assign_response
262 mock_runtime = mock_runtime_class.return_value
263 mock_runtime.execute_code.return_value = []
264
265 persisted = {}
266 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
267 mock_store.get.side_effect = lambda name: persisted.get("s")
268
269 result = runner.invoke(
270 app,
271 [
272 "run",
273 "--env",
274 "HF_TOKEN=abc",
275 "--env",
276 "B64=a=b=c",
277 str(script_path),
278 ],
279 )
280
281 assert result.exit_code == 0, result.output
282 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
283 body = next(c for c in code_calls if "hello from script" in c)
284 assert "os.environ['HF_TOKEN'] = 'abc'" in body
285 assert "os.environ['B64'] = 'a=b=c'" in body
286
287
288 def test_run_env_flag_escapes_tricky_literals(
289 mock_client,
290 mock_store,
291 mock_runtime_class,
292 mock_spawn_keep_alive,
293 assign_response,
294 script_path,
295 ):
296 """Quotes, backslashes, '=' and non-ASCII values should round-trip as safe
297 Python literals."""
298 mock_client.assign.return_value = assign_response
299 mock_runtime = mock_runtime_class.return_value
300 mock_runtime.execute_code.return_value = []
301
302 persisted = {}
303 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
304 mock_store.get.side_effect = lambda name: persisted.get("s")
305
306 value = "quote'back\\slash=µ"
307 result = runner.invoke(app, ["run", "--env", f"TRICKY={value}", str(script_path)])
308
309 assert result.exit_code == 0, result.output
310 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
311 body = next(c for c in code_calls if "hello from script" in c)
312 assert f"os.environ['TRICKY'] = {value!r}" in body
313
314
315 def test_run_sets_dunder_main(
316 mock_client,
317 mock_store,
318 mock_runtime_class,
319 mock_spawn_keep_alive,
320 assign_response,
321 script_path,
322 ):
323 """The script must run with __name__ == '__main__'."""
324 mock_client.assign.return_value = assign_response
325 mock_runtime = mock_runtime_class.return_value
326 mock_runtime.execute_code.return_value = []
327
328 persisted = {}
329 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
330 mock_store.get.side_effect = lambda name: persisted.get("s")
331
332 result = runner.invoke(app, ["run", str(script_path)])
333 assert result.exit_code == 0, result.output
334 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
335 body = next(c for c in code_calls if "hello from script" in c)
336 assert "__name__" in body and "'__main__'" in body
337
338
339 # ---------------------------------------------------------------------------
340 # Error handling
341 # ---------------------------------------------------------------------------
342
343
344 def test_run_propagates_error_exit_code(
345 mock_client,
346 mock_store,
347 mock_runtime_class,
348 mock_spawn_keep_alive,
349 assign_response,
350 script_path,
351 ):
352 """If the kernel reports an error, the CLI must exit non-zero AND still
353 unassign the VM (try/finally guarantee — AGENTS.md item 10)."""
354 mock_client.assign.return_value = assign_response
355 mock_runtime = mock_runtime_class.return_value
356
357 def execute_with_error(code, output_hook=None, **kwargs):
358 outputs = [
359 {
360 "output_type": "error",
361 "ename": "ValueError",
362 "evalue": "boom",
363 "traceback": ["Traceback...\n", "ValueError: boom\n"],
364 }
365 ]
366 if output_hook:
367 for o in outputs:
368 output_hook(o)
369 return outputs
370
371 mock_runtime.execute_code.side_effect = execute_with_error
372
373 persisted = {}
374 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
375 mock_store.get.side_effect = lambda name: persisted.get("s")
376
377 result = runner.invoke(app, ["run", str(script_path)])
378 assert result.exit_code != 0
379 # Cleanup MUST happen even on script failure.
380 mock_client.unassign.assert_called_once_with("ep-123")
381
382
383 def test_run_unassign_called_on_exception_during_execute(
384 mock_client,
385 mock_store,
386 mock_runtime_class,
387 mock_spawn_keep_alive,
388 assign_response,
389 script_path,
390 ):
391 """Even if `runtime.execute_code` raises (e.g. websocket dies), the VM
392 must be released."""
393 mock_client.assign.return_value = assign_response
394 mock_runtime = mock_runtime_class.return_value
395 mock_runtime.execute_code.side_effect = RuntimeError("websocket closed")
396
397 persisted = {}
398 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
399 mock_store.get.side_effect = lambda name: persisted.get("s")
400
401 result = runner.invoke(app, ["run", str(script_path)])
402 assert result.exit_code != 0
403 mock_client.unassign.assert_called_once_with("ep-123")
404
405
406 # ---------------------------------------------------------------------------
407 # Accelerator passthrough
408 # ---------------------------------------------------------------------------
409
410
411 def test_run_with_gpu_flag(
412 mock_client,
413 mock_store,
414 mock_runtime_class,
415 mock_spawn_keep_alive,
416 assign_response,
417 script_path,
418 ):
419 """`colab run --gpu T4 script.py` must request a T4 GPU."""
420 mock_client.assign.return_value = assign_response
421 mock_runtime = mock_runtime_class.return_value
422 mock_runtime.execute_code.return_value = []
423
424 persisted = {}
425 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
426 mock_store.get.side_effect = lambda name: persisted.get("s")
427
428 result = runner.invoke(app, ["run", "--gpu", "T4", str(script_path)])
429 assert result.exit_code == 0, result.output
430
431 _, kwargs = mock_client.assign.call_args
432 assert kwargs["variant"] is Variant.GPU
433 assert kwargs["accelerator"] is Accelerator.T4
434
435
436 def test_run_with_tpu_flag(
437 mock_client,
438 mock_store,
439 mock_runtime_class,
440 mock_spawn_keep_alive,
441 assign_response,
442 script_path,
443 ):
444 """`colab run --tpu v5e1 script.py` must request a TPU."""
445 mock_client.assign.return_value = assign_response
446 mock_runtime = mock_runtime_class.return_value
447 mock_runtime.execute_code.return_value = []
448
449 persisted = {}
450 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
451 mock_store.get.side_effect = lambda name: persisted.get("s")
452
453 result = runner.invoke(app, ["run", "--tpu", "v5e1", str(script_path)])
454 assert result.exit_code == 0, result.output
455
456 _, kwargs = mock_client.assign.call_args
457 assert kwargs["variant"] is Variant.TPU
458 assert kwargs["accelerator"] is Accelerator.V5E1
459
460
461 # ---------------------------------------------------------------------------
462 # Argument validation — fail FAST, before allocating a VM
463 # ---------------------------------------------------------------------------
464
465
466 def test_run_missing_script_errors(mock_client):
467 """Typer should reject the invocation if no script path is given."""
468 result = runner.invoke(app, ["run"])
469 assert result.exit_code != 0
470 mock_client.assign.assert_not_called()
471
472
473 def test_run_nonexistent_script_errors_before_assign(mock_client):
474 """If the script doesn't exist locally, fail BEFORE allocating a VM —
475 otherwise a typo would burn billable compute."""
476 result = runner.invoke(app, ["run", "/no/such/file.py"])
477 assert result.exit_code != 0
478 mock_client.assign.assert_not_called()
479
480
481 def test_run_malformed_env_errors_before_assign(mock_client, script_path):
482 """Malformed env entries must be rejected locally before any VM allocation."""
483 result = runner.invoke(app, ["run", str(script_path), "--env", "HF_TOKEN"])
484
485 assert result.exit_code != 0
486 assert "Expected KEY=VALUE" in result.output
487 mock_client.assign.assert_not_called()
488
489
490 # ---------------------------------------------------------------------------
491 # SystemExit handling — the kernel reports `sys.exit(N)` as an error output of
492 # `ename=='SystemExit'`. We want native-`python`-like semantics: exit 0 for
493 # `SystemExit(0)` (no traceback printed), and propagate the integer for
494 # `SystemExit(N)`.
495 # ---------------------------------------------------------------------------
496
497
498 def _systemexit_output(evalue: str):
499 """Shape of the kernel's error output for `raise SystemExit(<evalue>)`."""
500 return {
501 "output_type": "error",
502 "ename": "SystemExit",
503 "evalue": evalue,
504 "traceback": [
505 "An exception has occurred, use %tb to see the full traceback.\n",
506 f"\x1b[0;31mSystemExit\x1b[0m\x1b[0;31m:\x1b[0m {evalue}\n",
507 ],
508 }
509
510
511 def test_run_systemexit_zero_treated_as_success(
512 mock_client,
513 mock_store,
514 mock_runtime_class,
515 mock_spawn_keep_alive,
516 assign_response,
517 script_path,
518 capfd,
519 ):
520 """`raise SystemExit(0)` from the script body must NOT make the CLI exit
521 non-zero, AND the SystemExit traceback must NOT be printed (it's noise
522 that doesn't appear when running `python script.py`)."""
523 mock_client.assign.return_value = assign_response
524 mock_runtime = mock_runtime_class.return_value
525
526 def execute_with_systemexit(code, output_hook=None, **kwargs):
527 outputs = [_systemexit_output("0")]
528 if output_hook:
529 for o in outputs:
530 output_hook(o)
531 return outputs
532
533 mock_runtime.execute_code.side_effect = execute_with_systemexit
534
535 persisted = {}
536 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
537 mock_store.get.side_effect = lambda name: persisted.get("s")
538
539 result = runner.invoke(app, ["run", str(script_path)])
540 captured = capfd.readouterr()
541
542 assert result.exit_code == 0, result.output
543 # The IPython "An exception has occurred..." traceback must be suppressed.
544 assert "An exception has occurred" not in (
545 result.output + result.stderr + captured.out + captured.err
546 )
547 # Cleanup still happened.
548 mock_client.unassign.assert_called_once_with("ep-123")
549
550
551 def test_run_systemexit_nonzero_propagates_code(
552 mock_client,
553 mock_store,
554 mock_runtime_class,
555 mock_spawn_keep_alive,
556 assign_response,
557 script_path,
558 ):
559 """`raise SystemExit(7)` from the script must surface as exit code 7
560 (matching `python script.py` semantics)."""
561 mock_client.assign.return_value = assign_response
562 mock_runtime = mock_runtime_class.return_value
563
564 def execute_with_systemexit(code, output_hook=None, **kwargs):
565 outputs = [_systemexit_output("7")]
566 if output_hook:
567 for o in outputs:
568 output_hook(o)
569 return outputs
570
571 mock_runtime.execute_code.side_effect = execute_with_systemexit
572
573 persisted = {}
574 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
575 mock_store.get.side_effect = lambda name: persisted.get("s")
576
577 result = runner.invoke(app, ["run", str(script_path)])
578 assert result.exit_code == 7
579 mock_client.unassign.assert_called_once_with("ep-123")
580
581
582 def test_run_systemexit_string_message_exits_one(
583 mock_client,
584 mock_store,
585 mock_runtime_class,
586 mock_spawn_keep_alive,
587 assign_response,
588 script_path,
589 ):
590 """`sys.exit('boom')` (string arg, like `python -c "import sys; sys.exit(\"x\")"`)
591 must (a) exit non-zero (CPython uses 1) and (b) print the message so the
592 user sees what went wrong."""
593 mock_client.assign.return_value = assign_response
594 mock_runtime = mock_runtime_class.return_value
595
596 def execute_with_systemexit(code, output_hook=None, **kwargs):
597 outputs = [_systemexit_output("boom")]
598 if output_hook:
599 for o in outputs:
600 output_hook(o)
601 return outputs
602
603 mock_runtime.execute_code.side_effect = execute_with_systemexit
604
605 persisted = {}
606 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
607 mock_store.get.side_effect = lambda name: persisted.get("s")
608
609 result = runner.invoke(app, ["run", str(script_path)])
610 assert result.exit_code == 1
611 mock_client.unassign.assert_called_once_with("ep-123")
612
613
614 def test_run_prelude_suppresses_ipython_exit_warning(
615 mock_client,
616 mock_store,
617 mock_runtime_class,
618 mock_spawn_keep_alive,
619 assign_response,
620 script_path,
621 ):
622 """The prelude must mute IPython's 'To exit: use exit, quit, or Ctrl-D'
623 UserWarning, which fires whenever the user calls `sys.exit(...)` (i.e.
624 every well-formed CLI script)."""
625 mock_client.assign.return_value = assign_response
626 mock_runtime = mock_runtime_class.return_value
627 mock_runtime.execute_code.return_value = []
628
629 persisted = {}
630 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
631 mock_store.get.side_effect = lambda name: persisted.get("s")
632
633 result = runner.invoke(app, ["run", str(script_path)])
634 assert result.exit_code == 0, result.output
635
636 # Find the body-bearing execute_code call.
637 code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
638 body = next(c for c in code_calls if "hello from script" in c)
639 # Look for the warnings filter targeting IPython's exit-warning text.
640 assert "warnings.filterwarnings" in body
641 assert "To exit: use" in body
642
643
644 def test_run_with_timeout_flag(
645 mock_client,
646 mock_store,
647 mock_runtime_class,
648 mock_spawn_keep_alive,
649 assign_response,
650 script_path,
651 ):
652 """`colab run --timeout 3600 script.py` must pass timeout down to the runtime."""
653 mock_client.assign.return_value = assign_response
654 mock_runtime = mock_runtime_class.return_value
655 mock_runtime.execute_code.return_value = []
656
657 persisted = {}
658 mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
659 mock_store.get.side_effect = lambda name: persisted.get("s")
660
661 result = runner.invoke(app, ["run", "--timeout", "3600", str(script_path)])
662 assert result.exit_code == 0, result.output
663
664 code_calls = mock_runtime.execute_code.call_args_list
665 body_call = next(c for c in code_calls if "hello from script" in c.args[0])
666 assert body_call.kwargs.get("timeout") == 3600.0