google-colab-cli 0.5.6__tar.gz → 0.5.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/PKG-INFO +11 -5
  2. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/README.md +10 -1
  3. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/04_automation_and_utility.md +15 -9
  4. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/auto_update.py +9 -3
  5. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/session.py +4 -1
  6. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/utility.py +38 -13
  7. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_update.py +28 -2
  8. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_url.py +121 -18
  9. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/uv.lock +9 -7
  10. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.githooks/pre-commit +0 -0
  11. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.gitignore +0 -0
  12. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.pre-commit-config.yaml +0 -0
  13. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.python-version +0 -0
  14. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/AGENTS.md +0 -0
  15. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/COLAB_SKILL.md +0 -0
  16. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/CONTRIBUTING.md +0 -0
  17. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/LICENSE +0 -0
  18. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/cloudbuild.yaml +0 -0
  19. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/01_session_management.md +0 -0
  20. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/02_execution_and_interactive.md +0 -0
  21. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/03_file_management.md +0 -0
  22. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/05_run_command.md +0 -0
  23. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/3042ab12-2026-05-07.png +0 -0
  24. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/demos.md +0 -0
  25. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/README.md +0 -0
  26. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_keep_alive/test.sh +0 -0
  27. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_keep_alive_scope/test.sh +0 -0
  28. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_piped_console/test.sh +0 -0
  29. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_plot_redirection/test.sh +0 -0
  30. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_run_command/test.sh +0 -0
  31. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_variable_persistence/test.sh +0 -0
  32. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/pyproject.toml +3 -3
  33. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/auth.py +0 -0
  34. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/cli.py +0 -0
  35. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/client.py +0 -0
  36. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/__init__.py +0 -0
  37. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/automation.py +0 -0
  38. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/execution.py +0 -0
  39. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/files.py +0 -0
  40. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/run.py +0 -0
  41. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/common.py +0 -0
  42. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/console.py +0 -0
  43. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/contents.py +0 -0
  44. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/converter.py +0 -0
  45. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/history.py +0 -0
  46. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/repl.py +0 -0
  47. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/runtime.py +0 -0
  48. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/state.py +0 -0
  49. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/utils.py +0 -0
  50. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/conftest.py +0 -0
  51. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_auth.py +0 -0
  52. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_auth_adc.py +0 -0
  53. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_automation.py +0 -0
  54. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_cli.py +0 -0
  55. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_cli_log.py +0 -0
  56. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_client.py +0 -0
  57. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_console.py +0 -0
  58. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_contents.py +0 -0
  59. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_exec.py +0 -0
  60. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_history.py +0 -0
  61. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_ipynb_exec.py +0 -0
  62. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_keep_alive.py +0 -0
  63. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_log_export.py +0 -0
  64. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_pay.py +0 -0
  65. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_repl.py +0 -0
  66. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_resolution_logic.py +0 -0
  67. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_run.py +0 -0
  68. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_runtime.py +0 -0
  69. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_state.py +0 -0
  70. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_streaming.py +0 -0
  71. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_utils.py +0 -0
  72. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_version.py +0 -0
  73. {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-colab-cli
3
- Version: 0.5.6
3
+ Version: 0.5.7
4
4
  Summary: CLI for interacting with Colab.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -13,9 +13,6 @@ Requires-Dist: packaging>=24.0
13
13
  Requires-Dist: prompt-toolkit>=3.0.52
14
14
  Requires-Dist: pydantic>=2.12.5
15
15
  Requires-Dist: pygments>=2.19.2
16
- Requires-Dist: pytest-cov>=7.0.0
17
- Requires-Dist: pytest-mock>=3.15.1
18
- Requires-Dist: pytest>=9.0.2
19
16
  Requires-Dist: requests>=2.32.5
20
17
  Requires-Dist: rich>=14.3.3
21
18
  Requires-Dist: typer>=0.24.1
@@ -31,10 +28,19 @@ A command-line interface for Google Colab. Create sessions, run code, manage fil
31
28
 
32
29
  ## Install
33
30
 
31
+ With uv
32
+
33
+ ```bash
34
+ uv tool install -U google-colab-cli
35
+ ```
36
+
37
+ or with pip
38
+
34
39
  ```bash
35
- uv tool install git+https://github.com/googlecolab/google-colab-cli
40
+ pip install -U google-colab-cli
36
41
  ```
37
42
 
43
+
38
44
  Note: If you have a non-standard default package index (Googlers), you may also need to add `--index https://pypi.org/simple`.
39
45
 
40
46
 
@@ -6,10 +6,19 @@ A command-line interface for Google Colab. Create sessions, run code, manage fil
6
6
 
7
7
  ## Install
8
8
 
9
+ With uv
10
+
11
+ ```bash
12
+ uv tool install -U google-colab-cli
13
+ ```
14
+
15
+ or with pip
16
+
9
17
  ```bash
10
- uv tool install git+https://github.com/googlecolab/google-colab-cli
18
+ pip install -U google-colab-cli
11
19
  ```
12
20
 
21
+
13
22
  Note: If you have a non-standard default package index (Googlers), you may also need to add `--index https://pypi.org/simple`.
14
23
 
15
24
 
@@ -1,5 +1,8 @@
1
1
  ---
2
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.
3
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.
4
7
  2026-05-11: Removed the local-file update source (`update_file_path` setting and `_fetch_local` helper); `colab update` now consults PyPI only. Switched the default `update_url` to the canonical PyPI JSON API (`https://pypi.org/pypi/google-colab-cli/json`), which already exposes the `info.version` schema the auto-update subsystem expects. Re-added `colab update --install` as a public self-install path that runs `pip install -U google-colab-cli` against the current `sys.executable`; Linux-only (other platforms exit non-zero with an explanatory message), and a silent no-op when the cached `latest_version` is already at or below the current install.
5
8
  2026-05-12: Added an optional `timeout=` parameter to `ColabRuntime.execute_code` that flows through to both the `execute()` and `execute_interactive()` branches. `colab auth` and `colab drivemount` now pass `timeout=600` (10 min) via a shared `INTERACTIVE_AUTOMATION_TIMEOUT_SEC` constant in `commands/automation.py`. Background: `jupyter_kernel_client` defaults to a 10s wall-clock timeout that is consumed even when the kernel is idle waiting on `input_request`. With the drivefs hook intercepting that request and prompting the user to OAuth in their browser, any user that takes >10s to click through (essentially everyone) hit `TimeoutError` and saw "drivemount failed" even though the mount had actually succeeded server-side. The fix is scoped narrowly to the two human-in-the-loop subcommands; non-interactive paths (`colab exec`, `colab run`, `colab install`, `colab repl --pipe`, `colab console --pipe`) keep the upstream default since they receive continuous iopub traffic that resets the practical inactivity ceiling.
@@ -180,18 +183,21 @@ remediation guidance) rather than silently after ~1 minute via the daemon.
180
183
  across failed checks so transient network issues do not erase
181
184
  the cache.
182
185
  - **Notification**: If a new version is found, a non-intrusive message is
183
- printed to the console with a `Run 'pip install --upgrade colab' to
186
+ printed to the console with a `Run 'pip install --upgrade google-colab-cli' to
184
187
  update.` hint. The cached banner shown between fetches uses the generic
185
188
  `Run 'colab update' to update.` hint.
186
189
  - **Self-install (`--install`)**: An opt-in `--install` flag (default
187
- `False`) makes `colab update` shell out to `pip install -U
188
- google-colab-cli` (using `sys.executable` so the upgrade lands in the
189
- same interpreter the CLI is running under). **Linux only**; on other
190
- platforms the command exits non-zero with an explanatory message. When
191
- the cached `latest_version` is already at or below the current install,
192
- the flag is a silent no-op so it is safe to wire into automation. If
193
- `pip` exits non-zero, `colab update --install` propagates the same
194
- 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.
195
201
 
196
202
  ### 8. Identity Inspection (`colab whoami`) [developer-only]
197
203
 
@@ -140,7 +140,7 @@ def check_for_updates(quiet: bool = False) -> None:
140
140
  announce_upgrade(
141
141
  pypi_v,
142
142
  current,
143
- "pip install --upgrade colab",
143
+ "pip install --upgrade google-colab-cli",
144
144
  show_disable_hint=quiet,
145
145
  )
146
146
  elif not quiet:
@@ -216,10 +216,16 @@ PYPI_PACKAGE_NAME = "google-colab-cli"
216
216
 
217
217
 
218
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
220
  import sys
221
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
+
223
229
  typer.echo(f"[colab] Running: {' '.join(cmd)}")
224
230
  result = subprocess.run(cmd)
225
231
  if result.returncode != 0:
@@ -274,7 +274,10 @@ def restart(
274
274
  on_session_started=on_sess_started,
275
275
  )
276
276
 
277
- runtime.restart(timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC)
277
+ try:
278
+ runtime.restart()
279
+ finally:
280
+ runtime.stop()
278
281
 
279
282
 
280
283
  def sessions_command():
@@ -62,14 +62,27 @@ def url(
62
62
  ):
63
63
  """Print a browser URL that connects to an existing session.
64
64
 
65
- Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``,
65
+ Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>#datalabBackendUrl=<host>/tun/m/<endpoint>``,
66
66
  where the path is ``/tun/m/<endpoint>``. When opened, the Colab frontend
67
67
  skips ``/tun/m/assign`` and attaches the kernel to our existing VM.
68
68
 
69
- The ``dbu`` query parameter is the ``datalab_backend_url`` development
70
- flag. Because it's a development flag, URL-overriding it may be gated
71
- by the Colab frontend; some users may need to use the hash-based
72
- ``#datalabBackendUrl=...`` form instead.
69
+ Two backend-URL signals are embedded:
70
+
71
+ - ``?dbu=<urlencoded path>`` — the ``datalab_backend_url`` development
72
+ query flag. The frontend resolves the value against
73
+ ``window.location.origin``.
74
+
75
+ - ``#datalabBackendUrl=<full URL>`` — the hash-fragment form. Some
76
+ frontend code paths consult this first and ignore ``dbu``, so we
77
+ emit both for robustness. The fragment value is a FULL URL (with
78
+ scheme + host) and is intentionally NOT URL-encoded — browsers do
79
+ not decode the fragment before passing ``location.hash`` to page
80
+ JS, and Colab's hash parser expects the raw string.
81
+
82
+ The fragment's host always matches ``--host`` (the page origin), so
83
+ Colab's same-origin enforcement on the embedded backend URL doesn't
84
+ block the connection, and sandbox/dev users get a sandbox fragment
85
+ automatically.
73
86
  """
74
87
  # Imported here (not at module top) to mirror the lazy-state pattern used
75
88
  # elsewhere in this module and avoid a circular import via colab_cli.common.
@@ -83,14 +96,26 @@ def url(
83
96
  typer.echo(f"[colab] Session '{name}' not found.", err=True)
84
97
  raise typer.Exit(code=1)
85
98
 
86
- # Strip a trailing slash so we don't produce `https://host//notebooks/...`.
99
+ # Strip a trailing slash so we don't produce `https://host//notebooks/...`
100
+ # or `https://host//tun/m/...` in the fragment URL.
87
101
  host_clean = host.rstrip("/")
88
- # `dbu` value is the path `/tun/m/<endpoint>`. URL-encode it (incl. the
89
- # slashes via `safe=""`) so the value survives any downstream non-strict
90
- # query-string re-parsing — this is also the form shown in real Colab
91
- # connect URLs in the wild.
92
- dbu_value = quote(f"/tun/m/{s.endpoint}", safe="")
93
- connect_url = f"{host_clean}/notebooks/empty.ipynb?dbu={dbu_value}"
102
+ backend_path = f"/tun/m/{s.endpoint}"
103
+ # `dbu` value is the backend path. URL-encode it (incl. the slashes via
104
+ # `safe=""`) so the value survives any downstream non-strict query-string
105
+ # re-parsing — this is also the form shown in real Colab connect URLs.
106
+ dbu_value = quote(backend_path, safe="")
107
+ # `#datalabBackendUrl=` value is the FULL backend URL, raw (un-encoded):
108
+ # the browser does not decode the fragment before passing it to page JS,
109
+ # and Colab's hash parser calls `new URL(rawString)` directly. Pinning
110
+ # the host to `host_clean` (not hardcoding research.google.com) keeps
111
+ # this aligned with the page origin so same-origin enforcement passes
112
+ # for sandbox / dev hosts too.
113
+ fragment_value = f"{host_clean}{backend_path}"
114
+ connect_url = (
115
+ f"{host_clean}/notebooks/empty.ipynb"
116
+ f"?dbu={dbu_value}"
117
+ f"#datalabBackendUrl={fragment_value}"
118
+ )
94
119
 
95
120
  # Print the URL on its own line with no `[colab]` prefix so the output
96
121
  # is pipeable (`colab url -s s1 | xclip`, etc.).
@@ -344,7 +369,7 @@ def update_command(
344
369
  raise typer.Exit(code=1)
345
370
 
346
371
  # Skip the install when the current version already matches (or exceeds)
347
- # the latest known version, to avoid an unnecessary pip subprocess.
372
+ # the latest known version, to avoid an unnecessary subprocess call.
348
373
  settings = state.settings_store.load()
349
374
  if settings.latest_version and not auto_update._is_newer(
350
375
  settings.latest_version, auto_update.get_app_version()
@@ -127,7 +127,7 @@ def test_pypi_upgrade_uses_pip_hint(app_version, fake_settings, mock_pypi):
127
127
  result = runner.invoke(app, ["update"])
128
128
  assert result.exit_code == 0
129
129
  assert "available: 1.1.0 (current: 1.0.0)" in result.output
130
- assert "Run 'pip install --upgrade colab' to update." in result.output
130
+ assert "Run 'pip install --upgrade google-colab-cli' to update." in result.output
131
131
 
132
132
 
133
133
  def test_explicit_update_omits_disable_hint(app_version, fake_settings, mock_pypi):
@@ -398,6 +398,7 @@ def test_install_flag_runs_pip_install_upgrade(
398
398
  mock_pypi({"info": {"version": "1.1.0"}})
399
399
  fake_settings()
400
400
  mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
401
+ mocker.patch("sys.executable", "/usr/bin/python")
401
402
  run = mocker.patch(
402
403
  "colab_cli.auto_update.subprocess.run",
403
404
  return_value=mocker.Mock(returncode=0),
@@ -409,7 +410,32 @@ def test_install_flag_runs_pip_install_upgrade(
409
410
  args, _ = run.call_args
410
411
  # Use sys.executable to avoid PATH ambiguity / virtualenv mixups.
411
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"]
413
439
 
414
440
 
415
441
  def test_install_flag_errors_on_non_linux(
@@ -17,17 +17,26 @@ frontend to an existing colab-cli session.
17
17
 
18
18
  URL format:
19
19
 
20
- https://<host>/notebooks/empty.ipynb?dbu=<urlencoded-/tun/m/endpoint>
21
-
22
- The `dbu` query parameter is the Colab frontend's `datalab_backend_url`
23
- development flag; the frontend resolves the value against
24
- `window.location.origin` and attaches the kernel to the supplied
25
- `/tun/m/<endpoint>` path instead of allocating a fresh VM.
26
-
27
- Note: `dbu` is a development flag, so URL-overriding it may be gated by
28
- the Colab frontend; some users may need to use the hash-based
29
- `#datalabBackendUrl=...` form instead (we don't support that today; file
30
- an issue if you need it).
20
+ https://<host>/notebooks/empty.ipynb?dbu=<urlencoded-/tun/m/endpoint>#datalabBackendUrl=<host>/tun/m/<endpoint>
21
+
22
+ Two backend-URL signals are embedded:
23
+
24
+ - `?dbu=<urlencoded path>` -- the Colab frontend's
25
+ `datalab_backend_url` development query flag. The frontend resolves
26
+ the value against `window.location.origin` and attaches the kernel
27
+ to the supplied `/tun/m/<endpoint>` path instead of allocating a
28
+ fresh VM.
29
+
30
+ - `#datalabBackendUrl=<full URL>` -- the hash-fragment form. Some
31
+ Colab frontend code paths consult this first and ignore `dbu`, so we
32
+ emit both for robustness. The fragment value is a FULL URL (with
33
+ scheme + host) and is intentionally NOT URL-encoded -- browsers do
34
+ not decode fragment values before passing them to page JS, and
35
+ Colab's hash parser expects the raw string.
36
+
37
+ The fragment's host always matches the page origin (`--host`), so
38
+ same-origin enforcement in the frontend doesn't block the connection
39
+ and sandbox/dev users get a sandbox fragment automatically.
31
40
  """
32
41
 
33
42
  from unittest.mock import MagicMock, patch
@@ -59,10 +68,11 @@ def _parse_url_output(output: str) -> str:
59
68
  def test_url_explicit_session(mock_common_state):
60
69
  """`colab url -s NAME` prints the connect URL for that session.
61
70
 
62
- Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``.
71
+ Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>#datalabBackendUrl=<host>/tun/m/<endpoint>``.
63
72
  The path must land on `empty.ipynb` so the user sees a usable notebook
64
73
  UI; the `dbu` query param tells the frontend to skip /tun/m/assign and
65
- attach to our existing endpoint.
74
+ attach to our existing endpoint; the `#datalabBackendUrl=` fragment
75
+ is the alternative signal some frontend code paths consult.
66
76
  """
67
77
  s = _make_session(name="my-sess", endpoint="ep-XYZ")
68
78
  mock_common_state.store.get.return_value = s
@@ -90,6 +100,12 @@ def test_url_explicit_session(mock_common_state):
90
100
  # downstream re-parsing that treats the query string non-strictly.
91
101
  assert "dbu=%2Ftun%2Fm%2Fep-XYZ" in url
92
102
 
103
+ # Fragment: raw (not URL-encoded), full URL form, host matches page origin.
104
+ assert (
105
+ parsed.fragment
106
+ == "datalabBackendUrl=https://colab.research.google.com/tun/m/ep-XYZ"
107
+ )
108
+
93
109
 
94
110
  def test_url_resolves_unique_session(mock_common_state):
95
111
  """`colab url` (no -s) uses the unique-session resolution path."""
@@ -120,10 +136,16 @@ def test_url_session_not_found(mock_common_state):
120
136
 
121
137
 
122
138
  def test_url_custom_host(mock_common_state):
123
- """`--host` overrides the default frontend host. `dbu` is a path-only
124
- value (resolved against `window.location.origin` in the frontend), so
125
- the host swap only affects the page origin, not the embedded backend
126
- path."""
139
+ """`--host` overrides the default frontend host AND the host used in
140
+ the `#datalabBackendUrl=` fragment.
141
+
142
+ `dbu` itself is a path-only value (resolved against
143
+ `window.location.origin` in the frontend), so the host swap only
144
+ affects the page origin, not the embedded backend path. But the
145
+ fragment carries a full URL, and the Colab frontend enforces
146
+ same-origin between page and embedded backend URL -- so the fragment
147
+ host MUST match `--host` for the swap to work end-to-end.
148
+ """
127
149
  s = _make_session(endpoint="ep1")
128
150
  mock_common_state.store.get.return_value = s
129
151
  mock_common_state.resolve_session.return_value = "s1"
@@ -138,11 +160,17 @@ def test_url_custom_host(mock_common_state):
138
160
  assert parsed.netloc == "colab.sandbox.google.com"
139
161
  assert parsed.path == "/notebooks/empty.ipynb"
140
162
  assert parse_qs(parsed.query).get("dbu") == ["/tun/m/ep1"]
163
+ # Fragment host tracks --host (NOT pinned to research.google.com).
164
+ assert (
165
+ parsed.fragment
166
+ == "datalabBackendUrl=https://colab.sandbox.google.com/tun/m/ep1"
167
+ )
141
168
 
142
169
 
143
170
  def test_url_host_normalises_trailing_slash(mock_common_state):
144
171
  """`--host https://example.com/` (with trailing slash) must not produce
145
- a double slash before `/notebooks/empty.ipynb`."""
172
+ a double slash anywhere -- not before `/notebooks/empty.ipynb` in the
173
+ page URL, AND not before `/tun/m/...` in the fragment value."""
146
174
  s = _make_session(endpoint="ep2")
147
175
  mock_common_state.store.get.return_value = s
148
176
  mock_common_state.resolve_session.return_value = "s1"
@@ -154,6 +182,11 @@ def test_url_host_normalises_trailing_slash(mock_common_state):
154
182
  assert result.exit_code == 0
155
183
  assert "https://colab.research.google.com//notebooks/" not in result.output
156
184
  assert "https://colab.research.google.com/notebooks/empty.ipynb" in result.output
185
+ # Same guarantee for the fragment URL.
186
+ assert "https://colab.research.google.com//tun/" not in result.output
187
+ assert (
188
+ "datalabBackendUrl=https://colab.research.google.com/tun/m/ep2" in result.output
189
+ )
157
190
 
158
191
 
159
192
  def test_url_endpoint_with_special_chars_is_encoded(mock_common_state):
@@ -210,6 +243,76 @@ def test_url_no_open_by_default(mock_common_state):
210
243
  mock_open.assert_not_called()
211
244
 
212
245
 
246
+ def test_url_fragment_is_not_url_encoded(mock_common_state):
247
+ """The `#datalabBackendUrl=...` fragment value is a full URL and must
248
+ be embedded raw (no percent-encoding). Browsers do not decode the
249
+ fragment before passing `location.hash` to page JS, and the Colab
250
+ parser expects to call `new URL(rawString)` directly. If we encoded
251
+ `:` -> `%3A` or `/` -> `%2F` here, the parser would see
252
+ `https%3A%2F%2Fcolab...` and fail.
253
+
254
+ Concretely we should see the literal `://` and unescaped `/` in the
255
+ fragment, NOT their percent-encoded counterparts.
256
+ """
257
+ s = _make_session(endpoint="ep-RAW")
258
+ mock_common_state.store.get.return_value = s
259
+ mock_common_state.resolve_session.return_value = "s1"
260
+
261
+ result = runner.invoke(app, ["url", "-s", "s1"])
262
+ assert result.exit_code == 0, result.output
263
+ url = _parse_url_output(result.output)
264
+ parsed = urlparse(url)
265
+
266
+ # The fragment must contain the literal scheme + slashes...
267
+ assert "datalabBackendUrl=https://" in url
268
+ assert "/tun/m/ep-RAW" in parsed.fragment
269
+ # ...and MUST NOT contain percent-encoded versions of `:`, `/`.
270
+ assert "%3A" not in parsed.fragment
271
+ assert "%2F" not in parsed.fragment
272
+
273
+
274
+ def test_url_both_signals_present(mock_common_state):
275
+ """Invariant: every printed URL has BOTH `?dbu=` and `#datalabBackendUrl=`.
276
+
277
+ Either alone is unreliable across Colab frontend revisions; we emit
278
+ both so the frontend can use whichever it consults first.
279
+ """
280
+ s = _make_session(endpoint="ep-BOTH")
281
+ mock_common_state.store.get.return_value = s
282
+ mock_common_state.resolve_session.return_value = "s1"
283
+
284
+ result = runner.invoke(app, ["url", "-s", "s1"])
285
+ assert result.exit_code == 0, result.output
286
+ url = _parse_url_output(result.output)
287
+ assert "?dbu=" in url
288
+ assert "#datalabBackendUrl=" in url
289
+
290
+
291
+ def test_url_open_flag_includes_fragment(mock_common_state):
292
+ """`--open` opens the SAME URL it printed, including the fragment.
293
+
294
+ `webbrowser.open()` must receive the URL with the `#datalabBackendUrl=`
295
+ fragment intact -- otherwise the browser may attach to a fresh VM via
296
+ `/tun/m/assign` instead of our existing session.
297
+ """
298
+ s = _make_session(endpoint="ep-OPEN2")
299
+ mock_common_state.store.get.return_value = s
300
+ mock_common_state.resolve_session.return_value = "s1"
301
+
302
+ with patch("webbrowser.open") as mock_open:
303
+ result = runner.invoke(app, ["url", "-s", "s1", "--open"])
304
+
305
+ assert result.exit_code == 0, result.output
306
+ mock_open.assert_called_once()
307
+ opened_url = mock_open.call_args[0][0]
308
+ assert (
309
+ "#datalabBackendUrl=https://colab.research.google.com/tun/m/ep-OPEN2"
310
+ in opened_url
311
+ )
312
+ # And it's the same URL that got printed.
313
+ assert opened_url in result.output
314
+
315
+
213
316
  def test_url_output_is_pipeable(mock_common_state):
214
317
  """The printed URL line must be machine-parseable: a single line with no
215
318
  leading `[colab]` chatter, so `colab url -s s1 | xclip` works.
@@ -315,9 +315,6 @@ dependencies = [
315
315
  { name = "prompt-toolkit" },
316
316
  { name = "pydantic" },
317
317
  { name = "pygments" },
318
- { name = "pytest" },
319
- { name = "pytest-cov" },
320
- { name = "pytest-mock" },
321
318
  { name = "requests" },
322
319
  { name = "rich" },
323
320
  { name = "typer" },
@@ -327,6 +324,9 @@ dependencies = [
327
324
 
328
325
  [package.dev-dependencies]
329
326
  dev = [
327
+ { name = "pytest" },
328
+ { name = "pytest-cov" },
329
+ { name = "pytest-mock" },
330
330
  { name = "ruff" },
331
331
  ]
332
332
 
@@ -341,9 +341,6 @@ requires-dist = [
341
341
  { name = "prompt-toolkit", specifier = ">=3.0.52" },
342
342
  { name = "pydantic", specifier = ">=2.12.5" },
343
343
  { name = "pygments", specifier = ">=2.19.2" },
344
- { name = "pytest", specifier = ">=9.0.2" },
345
- { name = "pytest-cov", specifier = ">=7.0.0" },
346
- { name = "pytest-mock", specifier = ">=3.15.1" },
347
344
  { name = "requests", specifier = ">=2.32.5" },
348
345
  { name = "rich", specifier = ">=14.3.3" },
349
346
  { name = "typer", specifier = ">=0.24.1" },
@@ -352,7 +349,12 @@ requires-dist = [
352
349
  ]
353
350
 
354
351
  [package.metadata.requires-dev]
355
- dev = [{ name = "ruff", specifier = ">=0.15.6" }]
352
+ dev = [
353
+ { name = "pytest", specifier = ">=9.0.2" },
354
+ { name = "pytest-cov", specifier = ">=7.0.0" },
355
+ { name = "pytest-mock", specifier = ">=3.15.1" },
356
+ { name = "ruff", specifier = ">=0.15.6" },
357
+ ]
356
358
 
357
359
  [[package]]
358
360
  name = "idna"
@@ -14,9 +14,6 @@ dependencies = [
14
14
  "prompt-toolkit>=3.0.52",
15
15
  "pydantic>=2.12.5",
16
16
  "pygments>=2.19.2",
17
- "pytest>=9.0.2",
18
- "pytest-cov>=7.0.0",
19
- "pytest-mock>=3.15.1",
20
17
  "requests>=2.32.5",
21
18
  "rich>=14.3.3",
22
19
  "typer>=0.24.1",
@@ -48,5 +45,8 @@ jupyter-kernel-client = { git = "https://github.com/googlecolab/jupyter-kernel-c
48
45
 
49
46
  [dependency-groups]
50
47
  dev = [
48
+ "pytest>=9.0.2",
49
+ "pytest-cov>=7.0.0",
50
+ "pytest-mock>=3.15.1",
51
51
  "ruff>=0.15.6",
52
52
  ]