feat: Detect uv installation in `colab update --install` (#31)

Seth Troisi committed May 28, 2026 at 01:15 UTC 223df556fd4180e1704024d2b652e03a2dd9613f
4 files changed +48 -12
docs/04_automation_and_utility.md
+12 -8
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +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.
4 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.
5 2026-05-27: `colab url` now emits BOTH the `?dbu=<urlencoded path>` query parameter (existing) AND a new `#datalabBackendUrl=<full URL>` hash fragment (new). Format: `https://<host>/notebooks/empty.ipynb?dbu=%2Ftun%2Fm%2F<endpoint>#datalabBackendUrl=<host>/tun/m/<endpoint>`. Why both: some Colab frontend code paths consult the hash fragment first and ignore `dbu` entirely, so the previously-emitted query-only form failed silently for those users (the frontend fell through to allocating a fresh VM via `/tun/m/assign`). The fragment value is a FULL URL with scheme + host (NOT just the path) and is emitted RAW (no URL encoding) because browsers don't decode the fragment before passing `location.hash` to page JS — Colab's parser calls `new URL(rawString)` directly. The fragment host always matches `--host` so Colab's same-origin enforcement on embedded backend URLs doesn't block the connection, and sandbox/dev users (`--host https://colab.sandbox.google.com`) get a sandbox fragment automatically. Three new test cases in `tests/test_url.py` cover the raw-encoding requirement (`%3A`/`%2F` must NOT appear in the fragment), the both-signals-present invariant, and `--open` propagating the fragment to `webbrowser.open()`. Integration-verified live against synthetic session state with three host shapes (default, sandbox, trailing-slash); all produced correctly-shaped URLs with no `//` artifacts.
6 2026-05-07: Added a developer-only `colab whoami` subcommand (hidden from `colab --help`). Mints an access token via the same `auth.get_credentials(...)` path the rest of the CLI uses (honoring the global `--auth=...` flag), refreshes the credentials, then queries `https://oauth2.googleapis.com/tokeninfo` to print the email, scopes, audience, and expiry of whatever the CLI is about to send. Built specifically to short-circuit the "why is my call to colab.pa.googleapis.com 403-ing" debugging loop — the answer is almost always "missing scope" or "wrong identity", both of which `whoami` makes immediately visible. Hidden via `app.command(hidden=True)`; reachable via `colab whoami` or `colab whoami --help`. Suppressed from the daily-update banner check (added to `_AUTO_UPDATE_SUPPRESSED` in `cli.py`) so the banner doesn't obscure the auth output.
@@ -186,14 +187,17 @@ remediation guidance) rather than silently after ~1 minute via the daemon.
187 update.` hint. The cached banner shown between fetches uses the generic
188 `Run 'colab update' to update.` hint.
189 - **Self-install (`--install`)**: An opt-in `--install` flag (default
189 - `False`) makes `colab update` shell out to `pip install -U
190 - google-colab-cli` (using `sys.executable` so the upgrade lands in the
191 - same interpreter the CLI is running under). **Linux only**; on other
192 - platforms the command exits non-zero with an explanatory message. When
193 - the cached `latest_version` is already at or below the current install,
194 - the flag is a silent no-op so it is safe to wire into automation. If
195 - `pip` exits non-zero, `colab update --install` propagates the same
196 - exit code.
190 + `False`) makes `colab update` upgrade the CLI in place (**Linux only**).
191 + It detects how the CLI was installed:
192 + - If `sys.executable` contains `/uv/tools` (indicating it was installed via
193 + `uv tool install`), it runs `uv tool install -U google-colab-cli`.
194 + - Otherwise, runs `pip install -U google-colab-cli` using `sys.executable`
195 + to ensure the upgrade lands in the same interpreter.
196 + On other platforms, the command exits non-zero with an explanatory
197 + message. When the cached `latest_version` is already at or below the
198 + current install, the flag is a silent no-op so it is safe to wire into
199 + automation. If the upgrade command exits non-zero, `colab update --install`
200 + propagates the same exit code.
201
202 ### 8. Identity Inspection (`colab whoami`) [developer-only]
203
src/colab_cli/auto_update.py
+8 -2
@@ -216,10 +216,16 @@ PYPI_PACKAGE_NAME = "google-colab-cli"
216
217
218 def self_install() -> None:
219 - """Run ``pip install -U <PYPI_PACKAGE_NAME>`` to upgrade the CLI in place."""
219 + """Upgrade the CLI in place, detecting uv vs pip."""
220 import sys
221
222 - cmd = [sys.executable, "-m", "pip", "install", "-U", PYPI_PACKAGE_NAME]
222 + # If the executable path contains "/uv/", we assume it was installed via
223 + # `uv tool install` and use `uv` to upgrade it.
224 + if "/uv/tools/" in sys.executable:
225 + cmd = ["uv", "tool", "install", "-U", PYPI_PACKAGE_NAME]
226 + else:
227 + cmd = [sys.executable, "-m", "pip", "install", "-U", PYPI_PACKAGE_NAME]
228 +
229 typer.echo(f"[colab] Running: {' '.join(cmd)}")
230 result = subprocess.run(cmd)
231 if result.returncode != 0:
src/colab_cli/commands/utility.py
+1 -1
@@ -369,7 +369,7 @@ def update_command(
369 raise typer.Exit(code=1)
370
371 # Skip the install when the current version already matches (or exceeds)
372 - # the latest known version, to avoid an unnecessary pip subprocess.
372 + # the latest known version, to avoid an unnecessary subprocess call.
373 settings = state.settings_store.load()
374 if settings.latest_version and not auto_update._is_newer(
375 settings.latest_version, auto_update.get_app_version()
tests/test_update.py
+27 -1
@@ -398,6 +398,7 @@ def test_install_flag_runs_pip_install_upgrade(
398 mock_pypi({"info": {"version": "1.1.0"}})
399 fake_settings()
400 mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
401 + mocker.patch("sys.executable", "/usr/bin/python")
402 run = mocker.patch(
403 "colab_cli.auto_update.subprocess.run",
404 return_value=mocker.Mock(returncode=0),
@@ -409,7 +410,32 @@ def test_install_flag_runs_pip_install_upgrade(
410 args, _ = run.call_args
411 # Use sys.executable to avoid PATH ambiguity / virtualenv mixups.
412 cmd = args[0]
412 - assert cmd[1:] == ["-m", "pip", "install", "-U", "google-colab-cli"]
413 + assert cmd == ["/usr/bin/python", "-m", "pip", "install", "-U", "google-colab-cli"]
414 +
415 +
416 +def test_install_flag_runs_uv_tool_install(
417 + mocker, app_version, fake_settings, mock_pypi
418 +):
419 + """`colab update --install` shells out to `uv tool install -U google-colab-cli`
420 + when sys.executable contains '/uv/'."""
421 + app_version("1.0.0")
422 + mock_pypi({"info": {"version": "1.1.0"}})
423 + fake_settings()
424 + mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
425 + mocker.patch(
426 + "sys.executable", "/home/user/.local/share/uv/tools/google-colab-cli/bin/python"
427 + )
428 + run = mocker.patch(
429 + "colab_cli.auto_update.subprocess.run",
430 + return_value=mocker.Mock(returncode=0),
431 + )
432 +
433 + result = runner.invoke(app, ["update", "--install"])
434 + assert result.exit_code == 0
435 + assert run.call_count == 1
436 + args, _ = run.call_args
437 + cmd = args[0]
438 + assert cmd == ["uv", "tool", "install", "-U", "google-colab-cli"]
439
440
441 def test_install_flag_errors_on_non_linux(