diff --git a/docs/04_automation_and_utility.md b/docs/04_automation_and_utility.md index cefa1c1..5fa03a3 100644 --- a/docs/04_automation_and_utility.md +++ b/docs/04_automation_and_utility.md @@ -1,6 +1,6 @@ --- log: -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. +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. 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`. 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. 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. the cache. - **Notification**: If a new version is found, a non-intrusive message is printed to the console with a `Run 'pip install --upgrade google-colab-cli' to - update.` hint. On Linux platforms where `--install` self-update is supported, + update.` hint. On Linux and macOS platforms where `--install` self-update is supported, an additional hint `You can run 'colab update --install' to upgrade in place.` is displayed above the pip/uv install command. The cached banner shown between fetches uses the generic `Run 'colab update' to update.` hint. - **Self-install (`--install`)**: An opt-in `--install` flag (default - `False`) makes `colab update` upgrade the CLI in place (**Linux only**). + `False`) makes `colab update` upgrade the CLI in place (**Linux and macOS**). It detects how the CLI was installed: - If `sys.executable` contains `/uv/tools` (indicating it was installed via `uv tool install`), it runs `uv tool install -U google-colab-cli`. diff --git a/src/colab_cli/auto_update.py b/src/colab_cli/auto_update.py index 40dd166..8fec158 100644 --- a/src/colab_cli/auto_update.py +++ b/src/colab_cli/auto_update.py @@ -43,6 +43,11 @@ # ---------- Version detection ------------------------------------------- +def is_self_install_supported() -> bool: + """Return True if self-install (--install) is supported on the current platform.""" + return platform.system() in ("Linux", "Darwin") + + def get_app_version() -> str: """Return the installed package version, falling back to the git short hash.""" try: @@ -113,7 +118,7 @@ def announce_upgrade( typer.echo( f"\n[colab] A new version of Colab CLI is available: {latest} (current: {current})" ) - if platform.system() == "Linux" and ("pip" in install_cmd or "uv" in install_cmd): + if is_self_install_supported() and ("pip" in install_cmd or "uv" in install_cmd): typer.echo("[colab] You can run 'colab update --install' to upgrade in place.") typer.echo(f"[colab] Run '{install_cmd}' to update.") if show_disable_hint: @@ -132,7 +137,7 @@ def _get_install_command() -> str: """Return the recommended installation command based on the environment.""" import sys - if platform.system() == "Linux" and "/uv/tools/" in sys.executable: + if is_self_install_supported() and "/uv/tools/" in sys.executable: return f"uv tool install -U {PYPI_PACKAGE_NAME}" return f"pip install --upgrade {PYPI_PACKAGE_NAME}" diff --git a/src/colab_cli/commands/utility.py b/src/colab_cli/commands/utility.py index bd79db7..2b183d7 100644 --- a/src/colab_cli/commands/utility.py +++ b/src/colab_cli/commands/utility.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import platform from typing import Optional import typer @@ -362,9 +361,10 @@ def update_command( if not install: return - if platform.system() != "Linux": + if not auto_update.is_self_install_supported(): typer.echo( - "[colab] '--install' self-install is only supported on Linux.", err=True + "[colab] '--install' self-install is only supported on Linux and macOS.", + err=True, ) raise typer.Exit(code=1) diff --git a/tests/test_update.py b/tests/test_update.py index a0b8321..5b52229 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -163,7 +163,7 @@ def test_pypi_upgrade_uses_uv_hint(mocker, app_version, fake_settings, mock_pypi assert idx_install < idx_uv -def test_pypi_upgrade_uses_pip_hint_non_linux( +def test_pypi_upgrade_uses_pip_hint_macos( mocker, app_version, fake_settings, mock_pypi ): app_version("1.0.0") @@ -172,6 +172,30 @@ def test_pypi_upgrade_uses_pip_hint_non_linux( mocker.patch("sys.executable", "/usr/bin/python") mocker.patch("colab_cli.auto_update.platform.system", return_value="Darwin") + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "available: 1.1.0 (current: 1.0.0)" in result.output + assert "You can run 'colab update --install' to upgrade in place." in result.output + assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output + + idx_install = result.output.find( + "You can run 'colab update --install' to upgrade in place." + ) + idx_pip = result.output.find( + "Run 'pip install --upgrade google-colab-cli' to update." + ) + assert idx_install < idx_pip + + +def test_pypi_upgrade_uses_pip_hint_windows( + mocker, app_version, fake_settings, mock_pypi +): + app_version("1.0.0") + mock_pypi({"info": {"version": "1.1.0"}}) + fake_settings() + mocker.patch("sys.executable", "/usr/bin/python") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Windows") + result = runner.invoke(app, ["update"]) assert result.exit_code == 0 assert "available: 1.1.0 (current: 1.0.0)" in result.output @@ -448,7 +472,7 @@ def test_install_flag_runs_pip_install_upgrade( app_version("1.0.0") mock_pypi({"info": {"version": "1.1.0"}}) fake_settings() - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux") mocker.patch("sys.executable", "/usr/bin/python") run = mocker.patch( "colab_cli.auto_update.subprocess.run", @@ -472,7 +496,7 @@ def test_install_flag_runs_uv_tool_install( app_version("1.0.0") mock_pypi({"info": {"version": "1.1.0"}}) fake_settings() - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux") mocker.patch( "sys.executable", "/home/user/.local/share/uv/tools/google-colab-cli/bin/python" ) @@ -489,21 +513,41 @@ def test_install_flag_runs_uv_tool_install( assert cmd == ["uv", "tool", "install", "-U", "google-colab-cli"] -def test_install_flag_errors_on_non_linux( +def test_install_flag_errors_on_unsupported_platform( mocker, app_version, fake_settings, mock_pypi ): - """`--install` is gated to Linux; on other platforms the command must + """`--install` is gated to Linux and macOS; on other platforms the command must exit non-zero with an explanatory message and skip the pip subprocess.""" app_version("1.0.0") mock_pypi({"info": {"version": "1.1.0"}}) fake_settings() - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Darwin") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Windows") run = mocker.patch("colab_cli.auto_update.subprocess.run") result = runner.invoke(app, ["update", "--install"]) assert result.exit_code != 0 assert run.call_count == 0 - assert "only supported on Linux" in result.output + assert "only supported on Linux and macOS" in result.output + + +def test_install_flag_runs_on_macos(mocker, app_version, fake_settings, mock_pypi): + """`colab update --install` shells out to pip/uv when running on macOS.""" + app_version("1.0.0") + mock_pypi({"info": {"version": "1.1.0"}}) + fake_settings() + mocker.patch("colab_cli.auto_update.platform.system", return_value="Darwin") + mocker.patch("sys.executable", "/usr/bin/python") + run = mocker.patch( + "colab_cli.auto_update.subprocess.run", + return_value=mocker.Mock(returncode=0), + ) + + result = runner.invoke(app, ["update", "--install"]) + assert result.exit_code == 0 + assert run.call_count == 1 + args, _ = run.call_args + cmd = args[0] + assert cmd == ["/usr/bin/python", "-m", "pip", "install", "-U", "google-colab-cli"] 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( app_version("1.1.0") mock_pypi({"info": {"version": "1.1.0"}}) fake_settings() - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux") run = mocker.patch("colab_cli.auto_update.subprocess.run") result = runner.invoke(app, ["update", "--install"]) @@ -529,7 +573,7 @@ def test_install_flag_propagates_pip_failure( app_version("1.0.0") mock_pypi({"info": {"version": "1.1.0"}}) fake_settings() - mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux") + mocker.patch("colab_cli.auto_update.platform.system", return_value="Linux") mocker.patch( "colab_cli.auto_update.subprocess.run", return_value=mocker.Mock(returncode=2),