main
py 599 lines 20.3 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 import time
16 from unittest.mock import MagicMock, patch
17
18 import pytest
19 from typer.testing import CliRunner
20
21 from colab_cli.cli import app
22 from colab_cli.client import (
23 Assignment,
24 ColabRequestError,
25 PostAssignmentResponse,
26 )
27
28 runner = CliRunner()
29
30
31 @pytest.fixture
32 def mock_client(mock_common_state):
33 return mock_common_state.client
34
35
36 @pytest.fixture
37 def mock_store(mock_common_state):
38 return mock_common_state.store
39
40
41 @pytest.fixture
42 def mock_history(mock_common_state):
43 return mock_common_state.history
44
45
46 def test_cli_new_tpu(mock_client, mock_store):
47 mock_res = MagicMock()
48 mock_res.__class__ = PostAssignmentResponse
49 mock_res.runtime_proxy_info.token = "t1"
50 mock_res.runtime_proxy_info.url = "u1"
51 mock_res.endpoint = "e1"
52 mock_client.assign.return_value = mock_res
53
54 result = runner.invoke(app, ["new", "-s", "my-session", "--tpu", "v5e1"])
55 assert result.exit_code == 0
56
57 added_state = mock_store.add.call_args[0][0]
58 assert added_state.name == "my-session"
59 assert added_state.variant == "TPU"
60 assert added_state.accelerator == "V5E1"
61
62
63 def test_cli_new_gpu(mock_client, mock_store):
64 mock_res = MagicMock()
65 mock_res.__class__ = Assignment
66 mock_res.runtime_proxy_token = "t2"
67 mock_res.endpoint = "e2"
68 del mock_res.runtime_proxy_info
69 mock_client.assign.return_value = mock_res
70
71 result = runner.invoke(app, ["new", "-s", "gpu-sess", "--gpu", "A100"])
72 assert result.exit_code == 0
73
74 added_state = mock_store.add.call_args[0][0]
75 assert added_state.name == "gpu-sess"
76 assert added_state.variant == "GPU"
77 assert added_state.accelerator == "A100"
78 assert added_state.token == "t2"
79
80
81 @pytest.mark.parametrize(
82 "gpu_flag,expected_acc",
83 [
84 ("H100", "H100"),
85 ("l4", "L4"),
86 ("t4", "T4"),
87 ("g4", "G4"),
88 ],
89 )
90 def test_cli_new_gpu_variants(mock_client, mock_store, gpu_flag, expected_acc):
91 mock_res = MagicMock()
92 mock_res.__class__ = PostAssignmentResponse
93 mock_res.runtime_proxy_info.token = "t1"
94 mock_res.runtime_proxy_info.url = "u1"
95 mock_res.endpoint = "e1"
96 mock_client.assign.return_value = mock_res
97
98 result = runner.invoke(app, ["new", "-s", "s", "--gpu", gpu_flag])
99 assert result.exit_code == 0
100
101 added_state = mock_store.add.call_args[0][0]
102 assert added_state.accelerator == expected_acc
103
104
105 def test_cli_sessions_unified_format(mock_client, mock_common_state):
106 """`sessions` should lead each line with the local name when known:
107 `[name] endpoint | Hardware: X | Shape: Y | Variant: Z`.
108 """
109 mock_assignment = MagicMock()
110 mock_assignment.endpoint = "e1"
111 mock_assignment.variant.name = "GPU"
112 mock_assignment.accelerator.value = "T4"
113 mock_assignment.machine_shape.name = "STANDARD"
114
115 mock_session_state = MagicMock()
116 mock_session_state.name = "s1"
117 mock_session_state.endpoint = "e1"
118 mock_session_state.running = None
119
120 mock_common_state.sync_sessions.return_value = (
121 {"s1": mock_session_state},
122 [mock_assignment],
123 )
124
125 result = runner.invoke(app, ["sessions"])
126 assert result.exit_code == 0
127 assert "[s1] e1 | Hardware: T4 | Shape: Standard | Variant: GPU" in result.output
128
129
130 def test_cli_sessions_orphaned_assignment_marked(mock_client, mock_common_state):
131 """Server-side assignments without a local session should be marked `[?]`."""
132 mock_assignment = MagicMock()
133 mock_assignment.endpoint = "orphan-ep"
134 mock_assignment.variant.name = "DEFAULT"
135 mock_assignment.accelerator.value = "NONE"
136 mock_assignment.machine_shape.name = "HIGH_RAM"
137
138 mock_common_state.sync_sessions.return_value = ({}, [mock_assignment])
139
140 result = runner.invoke(app, ["sessions"])
141 assert result.exit_code == 0
142 # CPU is the alias for accelerator NONE
143 assert (
144 "[?] orphan-ep | Hardware: CPU | Shape: High-RAM | Variant: DEFAULT"
145 in result.output
146 )
147
148
149 def test_cli_sessions_no_assignments(mock_client, mock_common_state):
150 mock_common_state.sync_sessions.return_value = ({}, [])
151 result = runner.invoke(app, ["sessions"])
152 assert result.exit_code == 0
153 assert "No active sessions found on server." in result.output
154
155
156 def test_cli_status(mock_store, mock_common_state):
157 mock_session_state = MagicMock()
158 mock_session_state.name = "s1"
159 mock_session_state.endpoint = "e1"
160 mock_session_state.accelerator = "NONE"
161 mock_session_state.variant = "DEFAULT"
162 mock_session_state.machine_shape = "STANDARD"
163 mock_session_state.running = None
164 mock_session_state.last_execution = (
165 "my_notebook.ipynb",
166 "cell_1",
167 "2023-10-27 12:00:00",
168 )
169 mock_store.get.return_value = mock_session_state
170
171 mock_common_state.sync_sessions.return_value = ({"s1": mock_session_state}, [])
172
173 # Test with explicit session: uses unified format including endpoint and Status
174 result = runner.invoke(app, ["status", "-s", "s1"])
175 assert result.exit_code == 0
176 assert (
177 "[s1] e1 | Hardware: CPU | Shape: Standard | Variant: DEFAULT | Status: IDLE"
178 in result.output
179 )
180 assert (
181 "Last Execution: my_notebook.ipynb | Cell: cell_1 at 2023-10-27 12:00:00"
182 in result.output
183 )
184 mock_store.get.assert_called_with("s1")
185
186 # Test with missing session
187 mock_store.get.return_value = None
188 result = runner.invoke(app, ["status", "-s", "missing"])
189 assert result.exit_code == 0
190 assert "Session 'missing' not found" in result.output
191
192 # Test list all sessions: same unified format
193 mock_store.get.return_value = mock_session_state
194 result = runner.invoke(app, ["status"])
195 assert result.exit_code == 0
196 assert (
197 "[s1] e1 | Hardware: CPU | Shape: Standard | Variant: DEFAULT | Status: IDLE"
198 in result.output
199 )
200
201 # Test without execution metadata
202 mock_session_state.last_execution = None
203 mock_store.get.return_value = mock_session_state
204 result = runner.invoke(app, ["status", "-s", "s1"])
205 assert result.exit_code == 0
206 assert "Last Execution" not in result.output
207
208
209 def test_cli_status_running_shows_busy(mock_store, mock_common_state):
210 mock_session_state = MagicMock()
211 mock_session_state.name = "s1"
212 mock_session_state.endpoint = "e1"
213 mock_session_state.accelerator = "T4"
214 mock_session_state.variant = "GPU"
215 mock_session_state.machine_shape = "STANDARD"
216 mock_session_state.running = "exec.py"
217 mock_session_state.last_execution = None
218 mock_store.get.return_value = mock_session_state
219 mock_common_state.sync_sessions.return_value = ({"s1": mock_session_state}, [])
220
221 result = runner.invoke(app, ["status", "-s", "s1"])
222 assert result.exit_code == 0
223 assert (
224 "[s1] e1 | Hardware: T4 | Shape: Standard | Variant: GPU | Status: BUSY (exec.py)"
225 in result.output
226 )
227
228
229 def test_cli_new_high_mem(mock_client, mock_store):
230 mock_res = MagicMock()
231 mock_res.__class__ = PostAssignmentResponse
232 mock_res.runtime_proxy_info.token = "t1"
233 mock_res.runtime_proxy_info.url = "u1"
234 mock_res.endpoint = "e1"
235 mock_client.assign.return_value = mock_res
236
237 result = runner.invoke(app, ["new", "-s", "hm-sess", "--gpu", "A100", "--high-mem"])
238 assert result.exit_code == 0
239
240 mock_client.assign.assert_called_once()
241 _, kwargs = mock_client.assign.call_args
242 from colab_cli.client import Shape
243
244 assert kwargs["shape"] == Shape.HIGH_RAM
245
246 added_state = mock_store.add.call_args[0][0]
247 assert added_state.machine_shape == "HIGH_RAM"
248
249
250 def test_cli_session_resolution(mock_store, mock_common_state):
251 mock_session_state = MagicMock()
252 mock_session_state.name = "unique-session"
253 mock_session_state.endpoint = "e1"
254 mock_session_state.url = "http://url"
255 mock_session_state.token = "token"
256 mock_session_state.kernel_id = None
257
258 # Setup for resolve_session
259 mock_common_state.resolve_session.return_value = "unique-session"
260 mock_store.get.return_value = mock_session_state
261
262 result = runner.invoke(app, ["stop"])
263 assert result.exit_code == 0
264 mock_store.remove.assert_called_with("unique-session")
265
266
267 def test_cli_stop(mock_client, mock_store, mock_common_state):
268 mock_session_state = MagicMock()
269 mock_session_state.endpoint = "e1"
270 mock_session_state.name = "s1"
271 mock_session_state.url = "http://url"
272 mock_session_state.token = "token"
273 mock_session_state.kernel_id = None
274 mock_store.get.return_value = mock_session_state
275
276 mock_common_state.resolve_session.return_value = "s1"
277 result = runner.invoke(app, ["stop", "-s", "s1"])
278 assert result.exit_code == 0
279
280 mock_client.unassign.assert_called_with("e1")
281 mock_store.remove.assert_called_with("s1")
282
283
284 def test_cli_sessions_prune(mock_common_state):
285 mock_assignment = MagicMock()
286 mock_session_state1 = MagicMock()
287
288 mock_common_state.sync_sessions.return_value = (
289 {"s1": mock_session_state1},
290 [mock_assignment],
291 )
292 result = runner.invoke(app, ["sessions"])
293 assert result.exit_code == 0
294
295
296 def test_cli_new_no_name(mock_client, mock_store):
297 mock_res = MagicMock()
298 mock_res.__class__ = PostAssignmentResponse
299 mock_res.runtime_proxy_info.token = "t1"
300 mock_res.runtime_proxy_info.url = "u1"
301 mock_res.endpoint = "e1"
302 mock_client.assign.return_value = mock_res
303
304 result = runner.invoke(app, ["new"])
305 assert result.exit_code == 0
306
307 added_state = mock_store.add.call_args[0][0]
308 assert len(added_state.name) == 6
309
310
311 def test_cli_new_default_is_cpu(mock_client, mock_store):
312 """`colab new` with no flags must request a CPU runtime (no accelerator).
313 A GPU/TPU should only be requested when --gpu or --tpu is explicitly set.
314 """
315 from colab_cli.client import Accelerator, Variant
316
317 mock_res = MagicMock()
318 mock_res.__class__ = PostAssignmentResponse
319 mock_res.runtime_proxy_info.token = "t1"
320 mock_res.runtime_proxy_info.url = "u1"
321 mock_res.endpoint = "e1"
322 mock_client.assign.return_value = mock_res
323
324 result = runner.invoke(app, ["new"])
325 assert result.exit_code == 0
326
327 # The assign call must have used the DEFAULT (CPU) variant + NONE accelerator.
328 _, kwargs = mock_client.assign.call_args
329 assert kwargs["variant"] is Variant.DEFAULT
330 assert kwargs["accelerator"] is Accelerator.NONE
331
332 # The persisted SessionState should reflect the same.
333 added_state = mock_store.add.call_args[0][0]
334 assert added_state.variant == "DEFAULT"
335 assert added_state.accelerator == "NONE"
336
337
338 def test_cli_help():
339 result = runner.invoke(app, ["--help"])
340 assert result.exit_code == 0
341 assert "Usage:" in result.output
342 assert "Options" in result.output
343 assert "Commands" in result.output
344
345
346 def _extract_command_names(help_output: str) -> list[str]:
347 """Parse the command list out of a Typer/Click help output rendered
348 inside the `╭─ Commands ─...` rich box. Returns names in the order they
349 appear.
350 """
351 lines = help_output.splitlines()
352 in_commands = False
353 names = []
354 for line in lines:
355 if "Commands" in line and ("" in line or "-" in line):
356 in_commands = True
357 continue
358 if in_commands:
359 stripped = line.strip()
360 if stripped.startswith("") or stripped.startswith("`"):
361 break
362 # Lines look like: "│ help Show help for a command. │"
363 # Strip the rich box characters.
364 inner = stripped.strip("").strip()
365 if not inner:
366 continue
367 tok = inner.split()[0]
368 names.append(tok)
369 return names
370
371
372 def test_cli_help_commands_sorted_alphabetically():
373 """`colab --help` should list subcommands in alphabetical order so that
374 users (and docs) can find them deterministically."""
375 result = runner.invoke(app, ["--help"])
376 assert result.exit_code == 0
377 names = _extract_command_names(result.output)
378 assert names, f"Could not parse command names from help output:\n{result.output}"
379 assert names == sorted(names), (
380 f"Commands are not alphabetically sorted.\nGot: {names}\n"
381 f"Wanted: {sorted(names)}"
382 )
383
384
385 def test_cli_help_subcommand_commands_sorted_alphabetically():
386 """`colab help` (the help subcommand, no argument) should also list
387 subcommands alphabetically — it shares the parent group's renderer."""
388 result = runner.invoke(app, ["help"])
389 assert result.exit_code == 0
390 names = _extract_command_names(result.output)
391 assert names, f"Could not parse command names from help output:\n{result.output}"
392 assert names == sorted(names), (
393 f"`colab help` commands are not alphabetically sorted.\nGot: {names}\n"
394 f"Wanted: {sorted(names)}"
395 )
396
397
398 def test_cli_no_args():
399 result = runner.invoke(app, [])
400 # Typer with no_args_is_help=True might return 0 or 2 depending on version/config
401 assert result.exit_code in [0, 2]
402
403
404 def test_cli_console(mock_store, mock_common_state):
405 mock_session_state = MagicMock()
406 mock_session_state.name = "s1"
407 mock_session_state.token = "t1"
408 mock_session_state.url = "http://test.com"
409 mock_store.get.return_value = mock_session_state
410
411 mock_common_state.resolve_session.return_value = "s1"
412 with patch("colab_cli.commands.execution.connect_console") as mock_connect:
413 result = runner.invoke(app, ["console", "-s", "s1"])
414 assert result.exit_code == 0
415 mock_connect.assert_called_once_with(mock_session_state)
416
417
418 @patch("colab_cli.commands.files.ContentsClient")
419 def test_cli_ls(mock_contents_class, mock_store, mock_common_state):
420 mock_session_state = MagicMock()
421 mock_store.get.return_value = mock_session_state
422
423 mock_contents = mock_contents_class.return_value
424 mock_contents.list_dir.return_value = {
425 "type": "directory",
426 "content": [
427 {"name": "a_dir", "type": "directory"},
428 {"name": "b_file", "type": "file"},
429 ],
430 }
431
432 mock_common_state.resolve_session.return_value = "s1"
433 result = runner.invoke(app, ["ls", "-s", "s1", "content"])
434 assert result.exit_code == 0
435
436 assert "a_dir/" in result.output
437 assert "b_file" in result.output
438
439
440 @patch("colab_cli.commands.files.ContentsClient")
441 def test_cli_rm(mock_contents_class, mock_store, mock_common_state):
442 mock_session_state = MagicMock()
443 mock_store.get.return_value = mock_session_state
444
445 mock_common_state.resolve_session.return_value = "s1"
446 result = runner.invoke(app, ["rm", "-s", "s1", "content/file.txt"])
447 assert result.exit_code == 0
448
449 mock_contents_class.return_value.rm.assert_called_once_with("content/file.txt")
450 assert "Deleted content/file.txt" in result.output
451
452
453 @patch("colab_cli.commands.files.os.path.isfile")
454 @patch("colab_cli.commands.files.ContentsClient")
455 def test_cli_upload(mock_contents_class, mock_isfile, mock_store, mock_common_state):
456 mock_session_state = MagicMock()
457 mock_store.get.return_value = mock_session_state
458 mock_isfile.return_value = True
459
460 mock_common_state.resolve_session.return_value = "s1"
461 result = runner.invoke(app, ["upload", "-s", "s1", "local.txt", "remote.txt"])
462 assert result.exit_code == 0
463
464 mock_contents_class.return_value.upload.assert_called_once_with(
465 "local.txt", "remote.txt"
466 )
467 assert "Uploaded 'local.txt' to 'remote.txt'" in result.output
468
469
470 @patch("colab_cli.commands.files.ContentsClient")
471 def test_cli_download(mock_contents_class, mock_store, mock_common_state):
472 mock_session_state = MagicMock()
473 mock_store.get.return_value = mock_session_state
474
475 mock_common_state.resolve_session.return_value = "s1"
476 result = runner.invoke(app, ["download", "-s", "s1", "remote.txt", "local.txt"])
477 assert result.exit_code == 0
478
479 mock_contents_class.return_value.download.assert_called_once_with(
480 "remote.txt", "local.txt"
481 )
482 assert "Downloaded 'remote.txt' to 'local.txt'" in result.output
483
484
485 @patch("colab_cli.commands.files.ContentsClient")
486 @patch("click.edit")
487 def test_cli_edit_no_changes(
488 mock_edit, mock_contents_class, mock_store, mock_common_state
489 ):
490 mock_session_state = MagicMock()
491 mock_store.get.return_value = mock_session_state
492
493 mock_common_state.resolve_session.return_value = "s1"
494
495 # Simulate editor making no changes by not modifying the file
496 def mock_edit_side_effect(filename, **kwargs):
497 pass
498
499 mock_edit.side_effect = mock_edit_side_effect
500
501 result = runner.invoke(app, ["edit", "-s", "s1", "remote.txt"])
502
503 assert result.exit_code == 0
504 mock_contents_class.return_value.download.assert_called_once()
505 mock_contents_class.return_value.upload.assert_not_called()
506 assert "No changes made to 'remote.txt'" in result.output
507
508
509 @patch("colab_cli.commands.files.ContentsClient")
510 @patch("click.edit")
511 def test_cli_edit_with_changes(
512 mock_edit, mock_contents_class, mock_store, mock_common_state
513 ):
514 mock_session_state = MagicMock()
515 mock_store.get.return_value = mock_session_state
516
517 mock_common_state.resolve_session.return_value = "s1"
518
519 # Simulate editor modifying the file
520 def mock_edit_side_effect(filename, **kwargs):
521 time.sleep(0.01) # Ensure mtime differs if checking by mtime
522 with open(filename, "a") as f:
523 f.write("new content")
524
525 mock_edit.side_effect = mock_edit_side_effect
526
527 result = runner.invoke(app, ["edit", "-s", "s1", "remote.txt"])
528
529 assert result.exit_code == 0
530 mock_contents_class.return_value.download.assert_called_once()
531 mock_contents_class.return_value.upload.assert_called_once()
532 assert "Edited and uploaded 'remote.txt'" in result.output
533
534
535 def _make_400_error(message="Bad Request"):
536 """Build a ColabRequestError shaped like a 400 from the assign endpoint."""
537 response = MagicMock()
538 response.status_code = 400
539 response.reason = "Bad Request"
540 return ColabRequestError(message, request=MagicMock(), response=response)
541
542
543 def test_cli_new_400_with_gpu_shows_friendly_error(mock_client, mock_store):
544 """A 400 from `assign` when a GPU was requested should surface a friendly
545 message naming the accelerator and exit non-zero, NOT raise a traceback."""
546 mock_client.assign.side_effect = _make_400_error()
547
548 result = runner.invoke(app, ["new", "--gpu", "A100"])
549
550 assert result.exit_code != 0
551 # Friendly message should mention the accelerator we asked for
552 assert "A100" in result.output
553 # And give actionable hints
554 assert "quota" in result.output.lower() or "entitle" in result.output.lower()
555 # No partial state should be saved
556 mock_store.add.assert_not_called()
557
558
559 def test_cli_new_400_with_tpu_shows_friendly_error(mock_client, mock_store):
560 mock_client.assign.side_effect = _make_400_error()
561
562 result = runner.invoke(app, ["new", "--tpu", "v5e1"])
563
564 assert result.exit_code != 0
565 assert "V5E1" in result.output
566 mock_store.add.assert_not_called()
567
568
569 def test_cli_new_400_without_accelerator_propagates(mock_client, mock_store):
570 """If a 400 happens for a default (CPU) request, we cannot blame an
571 accelerator. The error should propagate so the user sees the real cause
572 rather than a misleading 'no quota' message.
573 """
574 mock_client.assign.side_effect = _make_400_error()
575
576 # Default `colab new` requests CPU (no --gpu, no --tpu).
577 result = runner.invoke(app, ["new"])
578
579 assert result.exit_code != 0
580 # The error message should NOT pretend it was an accelerator quota issue.
581 assert "quota" not in result.output.lower()
582
583
584 def test_cli_new_non_400_error_propagates(mock_client, mock_store):
585 """Errors with non-400 status should NOT be caught by the friendly
586 accelerator handler."""
587 response = MagicMock()
588 response.status_code = 500
589 response.reason = "Internal Server Error"
590 mock_client.assign.side_effect = ColabRequestError(
591 "boom", request=MagicMock(), response=response
592 )
593
594 result = runner.invoke(app, ["new", "--gpu", "A100"])
595
596 assert result.exit_code != 0
597 # Should not present the 400-specific friendly text
598 assert "quota" not in result.output.lower()
599 mock_store.add.assert_not_called()