main
py 583 lines 20.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 import json
16 from datetime import datetime, timedelta, timezone
17
18 import pytest
19 from typer.testing import CliRunner
20
21 from colab_cli.cli import app
22 from colab_cli.state import Settings
23
24 runner = CliRunner()
25
26
27 # ---------- Shared fixtures ----------------------------------------------
28
29
30 @pytest.fixture
31 def fake_settings(mocker):
32 """Return a builder that mocks the SettingsStore with caller-provided overrides.
33
34 The mocked ``load()`` returns a fresh copy of the seeded ``Settings`` on
35 each call, mirroring real on-disk reads. Mutations made by
36 ``check_for_updates`` to the loaded copy are captured via
37 ``SettingsStore.save`` (also mocked) and re-applied to the seed so that
38 subsequent ``load()`` calls reflect them — matching the persistence
39 behavior end-to-end.
40 """
41
42 def _build(**overrides):
43 kwargs = {
44 "update_url": "http://test.url",
45 "last_check": None,
46 **overrides,
47 }
48 # Wrap the live object in a list so the inner closures can rebind it
49 # while still letting tests read the latest state via ``current[0]``.
50 current = [Settings(**kwargs)]
51
52 def _load():
53 return current[0].model_copy()
54
55 def _save(updated):
56 current[0] = updated.model_copy()
57
58 mocker.patch("colab_cli.state.SettingsStore.load", side_effect=_load)
59 mocker.patch("colab_cli.state.SettingsStore.save", side_effect=_save)
60
61 # Expose a `.current` accessor returning the latest persisted state.
62 # The returned proxy supports attribute access for convenience.
63 class _Proxy:
64 def __getattr__(self, name):
65 return getattr(current[0], name)
66
67 return _Proxy()
68
69 return _build
70
71
72 @pytest.fixture
73 def mock_pypi(mocker):
74 """Stub ``urllib.request.urlopen`` to return ``payload`` (or raise ``error``)."""
75
76 def _mock(payload=None, *, error=None):
77 if error is not None:
78 mocker.patch("urllib.request.urlopen", side_effect=error)
79 return
80 m = mocker.patch("urllib.request.urlopen")
81 m.return_value.__enter__.return_value.read.return_value = json.dumps(
82 payload
83 ).encode("utf-8")
84
85 return _mock
86
87
88 @pytest.fixture
89 def app_version(mocker):
90 """Pin the locally-installed CLI version to the requested value."""
91
92 def _set(v):
93 mocker.patch("colab_cli.auto_update.get_app_version", return_value=v)
94
95 return _set
96
97
98 # ---------- PyPI source --------------------------------------------------
99
100
101 @pytest.mark.parametrize(
102 "current, pypi_version, expected_message",
103 [
104 # Same version: up-to-date with latest = current.
105 ("1.0.0", "1.0.0", "up to date (version: 1.0.0, latest: 1.0.0)"),
106 # PyPI is older than installed: still up-to-date, latest reflects PyPI.
107 ("1.1.0", "1.0.0", "up to date (version: 1.1.0, latest: 1.0.0)"),
108 ],
109 )
110 def test_pypi_no_upgrade(
111 app_version, fake_settings, mock_pypi, current, pypi_version, expected_message
112 ):
113 app_version(current)
114 mock_pypi({"info": {"version": pypi_version}})
115 fake_settings()
116
117 result = runner.invoke(app, ["update"])
118 assert result.exit_code == 0
119 assert expected_message in result.output
120
121
122 def test_pypi_upgrade_uses_pip_hint(mocker, app_version, fake_settings, mock_pypi):
123 app_version("1.0.0")
124 mock_pypi({"info": {"version": "1.1.0"}})
125 fake_settings()
126 mocker.patch("sys.executable", "/usr/bin/python")
127 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
128
129 result = runner.invoke(app, ["update"])
130 assert result.exit_code == 0
131 assert "available: 1.1.0 (current: 1.0.0)" in result.output
132 assert "You can run 'colab update --install' to upgrade in place." in result.output
133 assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output
134
135 idx_install = result.output.find(
136 "You can run 'colab update --install' to upgrade in place."
137 )
138 idx_pip = result.output.find(
139 "Run 'pip install --upgrade google-colab-cli' to update."
140 )
141 assert idx_install < idx_pip
142
143
144 def test_pypi_upgrade_uses_uv_hint(mocker, app_version, fake_settings, mock_pypi):
145 app_version("1.0.0")
146 mock_pypi({"info": {"version": "1.1.0"}})
147 fake_settings()
148 mocker.patch(
149 "sys.executable", "/home/user/.local/share/uv/tools/google-colab-cli/bin/python"
150 )
151 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
152
153 result = runner.invoke(app, ["update"])
154 assert result.exit_code == 0
155 assert "available: 1.1.0 (current: 1.0.0)" in result.output
156 assert "You can run 'colab update --install' to upgrade in place." in result.output
157 assert "Run 'uv tool install -U google-colab-cli' to update." in result.output
158
159 idx_install = result.output.find(
160 "You can run 'colab update --install' to upgrade in place."
161 )
162 idx_uv = result.output.find("Run 'uv tool install -U google-colab-cli' to update.")
163 assert idx_install < idx_uv
164
165
166 def test_pypi_upgrade_uses_pip_hint_macos(
167 mocker, app_version, fake_settings, mock_pypi
168 ):
169 app_version("1.0.0")
170 mock_pypi({"info": {"version": "1.1.0"}})
171 fake_settings()
172 mocker.patch("sys.executable", "/usr/bin/python")
173 mocker.patch("colab_cli.auto_update.platform.system", return_value="Darwin")
174
175 result = runner.invoke(app, ["update"])
176 assert result.exit_code == 0
177 assert "available: 1.1.0 (current: 1.0.0)" in result.output
178 assert "You can run 'colab update --install' to upgrade in place." in result.output
179 assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output
180
181 idx_install = result.output.find(
182 "You can run 'colab update --install' to upgrade in place."
183 )
184 idx_pip = result.output.find(
185 "Run 'pip install --upgrade google-colab-cli' to update."
186 )
187 assert idx_install < idx_pip
188
189
190 def test_pypi_upgrade_uses_pip_hint_windows(
191 mocker, app_version, fake_settings, mock_pypi
192 ):
193 app_version("1.0.0")
194 mock_pypi({"info": {"version": "1.1.0"}})
195 fake_settings()
196 mocker.patch("sys.executable", "/usr/bin/python")
197 mocker.patch("colab_cli.auto_update.platform.system", return_value="Windows")
198
199 result = runner.invoke(app, ["update"])
200 assert result.exit_code == 0
201 assert "available: 1.1.0 (current: 1.0.0)" in result.output
202 assert (
203 "You can run 'colab update --install' to upgrade in place." not in result.output
204 )
205 assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output
206
207
208 def test_explicit_update_omits_disable_hint(app_version, fake_settings, mock_pypi):
209 """`colab update` is explicit user opt-in; the 'how to silence' line
210 should NOT appear (it would be condescending after the user just asked)."""
211 app_version("1.0.0")
212 mock_pypi({"info": {"version": "1.1.0"}})
213 fake_settings()
214
215 result = runner.invoke(app, ["update"])
216 assert result.exit_code == 0
217 assert "available: 1.1.0" in result.output
218 assert "To silence this check" not in result.output
219 assert "enable_update_check" not in result.output
220
221
222 def test_background_check_includes_disable_hint(app_version, fake_settings, mock_pypi):
223 """The daily background fetch (triggered by any non-quiet command)
224 DOES include the 'how to silence' line so users have an obvious opt-out."""
225 app_version("1.0.0")
226 mock_pypi({"info": {"version": "1.1.0"}})
227 fake_settings(last_check=datetime.now(timezone.utc) - timedelta(days=2))
228
229 result = runner.invoke(app, ["sessions"])
230 assert result.exit_code == 0
231 assert "available: 1.1.0" in result.output
232 assert "To silence this check" in result.output
233 assert '"enable_update_check": false' in result.output
234
235
236 def test_cached_banner_includes_disable_hint(mocker, app_version, fake_settings):
237 """The cached banner shown between fetches is unsolicited; include the hint."""
238 app_version("1.0.0")
239 fake_settings(
240 last_check=datetime.now(timezone.utc) - timedelta(hours=1),
241 latest_version="1.2.0",
242 )
243 mocker.patch("colab_cli.auto_update.check_for_updates")
244
245 result = runner.invoke(app, ["sessions"])
246 assert result.exit_code == 0
247 assert "available: 1.2.0" in result.output
248 assert "To silence this check" in result.output
249
250
251 # ---------- Resilience --------------------------------------------------
252
253
254 def test_pypi_fetch_failure_omits_latest(app_version, fake_settings, mock_pypi):
255 app_version("1.0.0")
256 mock_pypi(error=OSError("network down"))
257 fake_settings()
258
259 result = runner.invoke(app, ["update"])
260 assert result.exit_code == 0
261 assert "Colab CLI is up to date (version: 1.0.0)" in result.output
262 assert "latest:" not in result.output
263
264
265 # ---------- Auto-update wiring ------------------------------------------
266
267
268 def test_auto_update_runs_when_stale(app_version, fake_settings, mock_pypi):
269 app_version("1.0.0")
270 mock_pypi({"info": {"version": "1.1.0"}})
271 fake_settings(last_check=datetime.now(timezone.utc) - timedelta(days=2))
272
273 result = runner.invoke(app, ["sessions"])
274 assert result.exit_code == 0
275 assert "available: 1.1.0 (current: 1.0.0)" in result.output
276
277
278 def test_auto_update_skips_when_recent(mocker, fake_settings):
279 fake_settings(last_check=datetime.now(timezone.utc) - timedelta(hours=1))
280 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
281
282 runner.invoke(app, ["sessions"])
283 assert mock_check.call_count == 0
284
285
286 def test_auto_update_runs_on_first_invocation(mocker, app_version, fake_settings):
287 app_version("1.0.0")
288 fake_settings() # last_check=None
289 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
290
291 result = runner.invoke(app, ["sessions"])
292 assert result.exit_code == 0
293 assert mock_check.call_count == 1
294
295
296 # ---------- `latest_version` cache --------------------------------------
297
298
299 def test_settings_default_latest_version_is_none():
300 assert Settings().latest_version is None
301
302
303 def test_check_persists_latest_version_from_pypi(app_version, fake_settings, mock_pypi):
304 app_version("1.0.0")
305 mock_pypi({"info": {"version": "1.1.0"}})
306 settings = fake_settings()
307
308 result = runner.invoke(app, ["update"])
309 assert result.exit_code == 0
310 assert settings.latest_version == "1.1.0"
311
312
313 def test_check_preserves_latest_version_on_fetch_failure(
314 app_version, fake_settings, mock_pypi
315 ):
316 """If the PyPI fetch fails, the cached `latest_version` must NOT be cleared."""
317 app_version("1.0.0")
318 mock_pypi(error=OSError("network down"))
319 settings = fake_settings(latest_version="1.7.0")
320
321 result = runner.invoke(app, ["update"])
322 assert result.exit_code == 0
323 assert settings.latest_version == "1.7.0"
324
325
326 def test_check_does_not_downgrade_latest_version(app_version, fake_settings, mock_pypi):
327 """A subsequent fetch returning an older version must not overwrite the cache."""
328 app_version("1.0.0")
329 mock_pypi({"info": {"version": "1.1.0"}})
330 settings = fake_settings(latest_version="2.0.0")
331
332 result = runner.invoke(app, ["update"])
333 assert result.exit_code == 0
334 assert settings.latest_version == "2.0.0"
335
336
337 # ---------- Cached banner on every invocation ---------------------------
338
339
340 def test_cached_banner_shown_when_throttled(mocker, app_version, fake_settings):
341 """When the daily fetch is skipped, a cached newer `latest_version` still
342 triggers the upgrade banner — without re-fetching."""
343 app_version("1.0.0")
344 fake_settings(
345 last_check=datetime.now(timezone.utc) - timedelta(hours=1),
346 latest_version="1.2.0",
347 )
348 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
349
350 result = runner.invoke(app, ["sessions"])
351 assert result.exit_code == 0
352 assert mock_check.call_count == 0 # throttle still active
353 assert "available: 1.2.0 (current: 1.0.0)" in result.output
354
355
356 def test_cached_banner_suppressed_when_up_to_date(mocker, app_version, fake_settings):
357 """If the cached `latest_version` is not newer than the current install,
358 no banner should appear."""
359 app_version("1.2.0")
360 fake_settings(
361 last_check=datetime.now(timezone.utc) - timedelta(hours=1),
362 latest_version="1.2.0",
363 )
364 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
365
366 result = runner.invoke(app, ["sessions"])
367 assert result.exit_code == 0
368 assert mock_check.call_count == 0
369 assert "A new version" not in result.output
370
371
372 def test_cached_banner_skipped_for_update_subcommand(
373 mocker, app_version, fake_settings
374 ):
375 """`colab update` does its own fetch + announce; the callback must not
376 duplicate the banner from the cache."""
377 app_version("1.0.0")
378 fake_settings(
379 last_check=datetime.now(timezone.utc) - timedelta(hours=1),
380 latest_version="1.2.0",
381 )
382 # Stub check_for_updates so we can assert the callback didn't print twice.
383 mock_check = mocker.patch(
384 "colab_cli.auto_update.check_for_updates", return_value=None
385 )
386
387 result = runner.invoke(app, ["update"])
388 assert result.exit_code == 0
389 # The check ran (forced by `update`) but the cached banner from the
390 # callback must NOT have fired.
391 assert mock_check.call_count == 1
392 assert result.output.count("A new version") == 0
393
394
395 def test_cached_banner_suppressed_when_update_check_disabled(
396 mocker, app_version, fake_settings
397 ):
398 """`enable_update_check=False` is a global opt-out: no fetch AND no cached
399 banner. The user has explicitly disabled the update-check subsystem."""
400 app_version("1.0.0")
401 fake_settings(
402 enable_update_check=False,
403 latest_version="1.2.0",
404 )
405 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
406
407 result = runner.invoke(app, ["sessions"])
408 assert result.exit_code == 0
409 assert mock_check.call_count == 0
410 assert "A new version" not in result.output
411
412
413 # ---------- Quiet subcommands skip the auto-update banner ---------------
414
415
416 @pytest.mark.parametrize("subcommand", ["version", "log", "pay", "help"])
417 def test_background_check_skipped_for_quiet_subcommands(
418 mocker, app_version, fake_settings, subcommand
419 ):
420 """`version`, `log`, `pay`, and `help` are short-lived informational
421 commands. Their output should never be polluted by the upgrade banner —
422 no daily fetch and no cached banner should fire from the global
423 callback. (`colab update` is exempted separately because it runs its
424 own check.)"""
425 app_version("1.0.0")
426 fake_settings(
427 # Force the daily fetch to be DUE: if the callback runs at all, it
428 # would call check_for_updates() and we'd see the assertion fail.
429 last_check=datetime.now(timezone.utc) - timedelta(days=2),
430 # Also seed a cached newer version so we'd see the cached banner if
431 # the callback fell through to maybe_show_cached_banner instead.
432 latest_version="1.2.0",
433 )
434 # Patch `webbrowser.open` to keep `colab pay` from launching a browser
435 # in the test environment.
436 mocker.patch("webbrowser.open")
437 mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
438
439 result = runner.invoke(app, [subcommand])
440 assert result.exit_code == 0, result.output
441 assert mock_check.call_count == 0, (
442 f"`colab {subcommand}` should NOT trigger the daily update fetch."
443 )
444 assert "A new version" not in result.output, (
445 f"`colab {subcommand}` should NOT print the cached upgrade banner."
446 )
447
448
449 # ---------- `--install` self-install flag -------------------------------
450
451
452 def test_install_flag_default_does_not_install(
453 mocker, app_version, fake_settings, mock_pypi
454 ):
455 """Without `--install`, no install command is invoked even when a newer
456 version is available on PyPI."""
457 app_version("1.0.0")
458 mock_pypi({"info": {"version": "1.1.0"}})
459 fake_settings()
460 run = mocker.patch("colab_cli.auto_update.subprocess.run")
461
462 result = runner.invoke(app, ["update"])
463 assert result.exit_code == 0
464 assert run.call_count == 0
465
466
467 def test_install_flag_runs_pip_install_upgrade(
468 mocker, app_version, fake_settings, mock_pypi
469 ):
470 """`colab update --install` shells out to `pip install -U google-colab-cli`
471 when PyPI reports a newer version."""
472 app_version("1.0.0")
473 mock_pypi({"info": {"version": "1.1.0"}})
474 fake_settings()
475 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
476 mocker.patch("sys.executable", "/usr/bin/python")
477 run = mocker.patch(
478 "colab_cli.auto_update.subprocess.run",
479 return_value=mocker.Mock(returncode=0),
480 )
481
482 result = runner.invoke(app, ["update", "--install"])
483 assert result.exit_code == 0
484 assert run.call_count == 1
485 args, _ = run.call_args
486 # Use sys.executable to avoid PATH ambiguity / virtualenv mixups.
487 cmd = args[0]
488 assert cmd == ["/usr/bin/python", "-m", "pip", "install", "-U", "google-colab-cli"]
489
490
491 def test_install_flag_runs_uv_tool_install(
492 mocker, app_version, fake_settings, mock_pypi
493 ):
494 """`colab update --install` shells out to `uv tool install -U google-colab-cli`
495 when sys.executable contains '/uv/'."""
496 app_version("1.0.0")
497 mock_pypi({"info": {"version": "1.1.0"}})
498 fake_settings()
499 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
500 mocker.patch(
501 "sys.executable", "/home/user/.local/share/uv/tools/google-colab-cli/bin/python"
502 )
503 run = mocker.patch(
504 "colab_cli.auto_update.subprocess.run",
505 return_value=mocker.Mock(returncode=0),
506 )
507
508 result = runner.invoke(app, ["update", "--install"])
509 assert result.exit_code == 0
510 assert run.call_count == 1
511 args, _ = run.call_args
512 cmd = args[0]
513 assert cmd == ["uv", "tool", "install", "-U", "google-colab-cli"]
514
515
516 def test_install_flag_errors_on_unsupported_platform(
517 mocker, app_version, fake_settings, mock_pypi
518 ):
519 """`--install` is gated to Linux and macOS; on other platforms the command must
520 exit non-zero with an explanatory message and skip the pip subprocess."""
521 app_version("1.0.0")
522 mock_pypi({"info": {"version": "1.1.0"}})
523 fake_settings()
524 mocker.patch("colab_cli.auto_update.platform.system", return_value="Windows")
525 run = mocker.patch("colab_cli.auto_update.subprocess.run")
526
527 result = runner.invoke(app, ["update", "--install"])
528 assert result.exit_code != 0
529 assert run.call_count == 0
530 assert "only supported on Linux and macOS" in result.output
531
532
533 def test_install_flag_runs_on_macos(mocker, app_version, fake_settings, mock_pypi):
534 """`colab update --install` shells out to pip/uv when running on macOS."""
535 app_version("1.0.0")
536 mock_pypi({"info": {"version": "1.1.0"}})
537 fake_settings()
538 mocker.patch("colab_cli.auto_update.platform.system", return_value="Darwin")
539 mocker.patch("sys.executable", "/usr/bin/python")
540 run = mocker.patch(
541 "colab_cli.auto_update.subprocess.run",
542 return_value=mocker.Mock(returncode=0),
543 )
544
545 result = runner.invoke(app, ["update", "--install"])
546 assert result.exit_code == 0
547 assert run.call_count == 1
548 args, _ = run.call_args
549 cmd = args[0]
550 assert cmd == ["/usr/bin/python", "-m", "pip", "install", "-U", "google-colab-cli"]
551
552
553 def test_install_flag_no_op_when_already_up_to_date(
554 mocker, app_version, fake_settings, mock_pypi
555 ):
556 """`--install` should not invoke pip when the cached `latest_version`
557 is not newer than the current install."""
558 app_version("1.1.0")
559 mock_pypi({"info": {"version": "1.1.0"}})
560 fake_settings()
561 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
562 run = mocker.patch("colab_cli.auto_update.subprocess.run")
563
564 result = runner.invoke(app, ["update", "--install"])
565 assert result.exit_code == 0
566 assert run.call_count == 0
567
568
569 def test_install_flag_propagates_pip_failure(
570 mocker, app_version, fake_settings, mock_pypi
571 ):
572 """If `pip install -U` exits non-zero, `colab update --install` must too."""
573 app_version("1.0.0")
574 mock_pypi({"info": {"version": "1.1.0"}})
575 fake_settings()
576 mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux")
577 mocker.patch(
578 "colab_cli.auto_update.subprocess.run",
579 return_value=mocker.Mock(returncode=2),
580 )
581
582 result = runner.invoke(app, ["update", "--install"])
583 assert result.exit_code == 2