feat: Support macOS for in-place self-update (#37)

Refactored the platform checks for the `colab update --install` command to enable support for macOS (Darwin) in addition to Linux. Added a helper `is_self_install_supported()` in `auto_update.py`.

Seth Troisi committed Jun 2, 2026 at 09:58 UTC ff13ccf3193cc0acd2612f7ca65388b596faf863
4 files changed +66 -17
docs/04_automation_and_utility.md
+3 -3
@@ -1,6 +1,6 @@
1 ---
2 log:
3 -2026-06-01: Improved `colab update` output. On Linux platforms, an additional message is shown recommending `colab update --install` to upgrade in place, positioned above the standard `pip`/`uv` installation command.
3 +2026-06-01: Enabled `colab update --install` self-update on macOS in addition to Linux. Refactored platform check logic to keep the implementation DRY and updated both tests and documentation. Also, on these platforms, an additional message is shown recommending `colab update --install` to upgrade in place, positioned above the standard `pip`/`uv` installation command.
4 2026-05-27: Refactored `colab README` and `colab AGENT` to bundle `README.md` and `AGENTS.md` via Hatchling's `force-include` and read them using `importlib.resources` instead of `importlib.metadata`. `colab AGENT` now correctly prints `AGENTS.md`.
5 2026-05-27: Extended `colab update --install` to detect if the CLI was installed via `uv tool install` (by checking if `sys.executable` contains `/uv/`) and if so, use `uv tool install -U google-colab-cli` to upgrade.
6 2026-05-27: Updated auto-update upgrade hint to recommend `pip install --upgrade google-colab-cli` instead of `colab`, aligning with the PyPI package name.
@@ -186,12 +186,12 @@ remediation guidance) rather than silently after ~1 minute via the daemon.
186 the cache.
187 - **Notification**: If a new version is found, a non-intrusive message is
188 printed to the console with a `Run 'pip install --upgrade google-colab-cli' to
189 - update.` hint. On Linux platforms where `--install` self-update is supported,
189 + update.` hint. On Linux and macOS platforms where `--install` self-update is supported,
190 an additional hint `You can run 'colab update --install' to upgrade in place.`
191 is displayed above the pip/uv install command. The cached banner shown between
192 fetches uses the generic `Run 'colab update' to update.` hint.
193 - **Self-install (`--install`)**: An opt-in `--install` flag (default
194 - `False`) makes `colab update` upgrade the CLI in place (**Linux only**).
194 + `False`) makes `colab update` upgrade the CLI in place (**Linux and macOS**).
195 It detects how the CLI was installed:
196 - If `sys.executable` contains `/uv/tools` (indicating it was installed via
197 `uv tool install`), it runs `uv tool install -U google-colab-cli`.
src/colab_cli/auto_update.py
+7 -2
@@ -43,6 +43,11 @@ PYPI_PACKAGE_NAME = "google-colab-cli"
43 # ---------- Version detection -------------------------------------------
44
45
46 +def is_self_install_supported() -> bool:
47 + """Return True if self-install (--install) is supported on the current platform."""
48 + return platform.system() in ("Linux", "Darwin")
49 +
50 +
51 def get_app_version() -> str:
52 """Return the installed package version, falling back to the git short hash."""
53 try:
@@ -113,7 +118,7 @@ def announce_upgrade(
118 typer.echo(
119 f"\n[colab] A new version of Colab CLI is available: {latest} (current: {current})"
120 )
116 - if platform.system() == "Linux" and ("pip" in install_cmd or "uv" in install_cmd):
121 + if is_self_install_supported() and ("pip" in install_cmd or "uv" in install_cmd):
122 typer.echo("[colab] You can run 'colab update --install' to upgrade in place.")
123 typer.echo(f"[colab] Run '{install_cmd}' to update.")
124 if show_disable_hint:
@@ -132,7 +137,7 @@ def _get_install_command() -> str:
137 """Return the recommended installation command based on the environment."""
138 import sys
139
135 - if platform.system() == "Linux" and "/uv/tools/" in sys.executable:
140 + if is_self_install_supported() and "/uv/tools/" in sys.executable:
141 return f"uv tool install -U {PYPI_PACKAGE_NAME}"
142 return f"pip install --upgrade {PYPI_PACKAGE_NAME}"
143
src/colab_cli/commands/utility.py
+3 -3
@@ -12,7 +12,6 @@
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 -import platform
15 from typing import Optional
16
17 import typer
@@ -362,9 +361,10 @@ def update_command(
361 if not install:
362 return
363
365 - if platform.system() != "Linux":
364 + if not auto_update.is_self_install_supported():
365 typer.echo(
367 - "[colab] '--install' self-install is only supported on Linux.", err=True
366 + "[colab] '--install' self-install is only supported on Linux and macOS.",
367 + err=True,
368 )
369 raise typer.Exit(code=1)
370
tests/test_update.py
+53 -9
@@ -163,7 +163,7 @@ def test_pypi_upgrade_uses_uv_hint(mocker, app_version, fake_settings, mock_pypi
163 assert idx_install < idx_uv
164
165
166 -def test_pypi_upgrade_uses_pip_hint_non_linux(
166 +def test_pypi_upgrade_uses_pip_hint_macos(
167 mocker, app_version, fake_settings, mock_pypi
168 ):
169 app_version("1.0.0")
@@ -172,6 +172,30 @@ def test_pypi_upgrade_uses_pip_hint_non_linux(
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
@@ -448,7 +472,7 @@ def test_install_flag_runs_pip_install_upgrade(
472 app_version("1.0.0")
473 mock_pypi({"info": {"version": "1.1.0"}})
474 fake_settings()
451 - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
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",
@@ -472,7 +496,7 @@ def test_install_flag_runs_uv_tool_install(
496 app_version("1.0.0")
497 mock_pypi({"info": {"version": "1.1.0"}})
498 fake_settings()
475 - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
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 )
@@ -489,21 +513,41 @@ def test_install_flag_runs_uv_tool_install(
513 assert cmd == ["uv", "tool", "install", "-U", "google-colab-cli"]
514
515
492 -def test_install_flag_errors_on_non_linux(
516 +def test_install_flag_errors_on_unsupported_platform(
517 mocker, app_version, fake_settings, mock_pypi
518 ):
495 - """`--install` is gated to Linux; on other platforms the command must
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()
500 - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Darwin")
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
506 - assert "only supported on Linux" in result.output
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(
@@ -514,7 +558,7 @@ def test_install_flag_no_op_when_already_up_to_date(
558 app_version("1.1.0")
559 mock_pypi({"info": {"version": "1.1.0"}})
560 fake_settings()
517 - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
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"])
@@ -529,7 +573,7 @@ def test_install_flag_propagates_pip_failure(
573 app_version("1.0.0")
574 mock_pypi({"info": {"version": "1.1.0"}})
575 fake_settings()
532 - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
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),