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.
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/PKG-INFO +11 -5
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/README.md +10 -1
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/04_automation_and_utility.md +15 -9
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/auto_update.py +9 -3
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/session.py +4 -1
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/utility.py +38 -13
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_update.py +28 -2
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_url.py +121 -18
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/uv.lock +9 -7
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.githooks/pre-commit +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.gitignore +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.pre-commit-config.yaml +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/.python-version +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/AGENTS.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/COLAB_SKILL.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/CONTRIBUTING.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/LICENSE +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/cloudbuild.yaml +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/01_session_management.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/02_execution_and_interactive.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/03_file_management.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/05_run_command.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/3042ab12-2026-05-07.png +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/docs/demos.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/README.md +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_keep_alive/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_keep_alive_scope/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_piped_console/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_plot_redirection/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_run_command/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_variable_persistence/test.sh +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/pyproject.toml +3 -3
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/auth.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/cli.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/client.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/__init__.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/automation.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/execution.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/files.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/commands/run.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/common.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/console.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/contents.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/converter.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/history.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/repl.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/runtime.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/state.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/src/colab_cli/utils.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/conftest.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_auth.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_auth_adc.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_automation.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_cli.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_cli_log.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_client.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_console.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_contents.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_exec.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_history.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_ipynb_exec.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_keep_alive.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_log_export.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_pay.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_repl.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_resolution_logic.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_run.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_runtime.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_state.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_streaming.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_utils.py +0 -0
- {google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/tests/test_version.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
"""
|
|
219
|
+
"""Upgrade the CLI in place, detecting uv vs pip."""
|
|
220
220
|
import sys
|
|
221
221
|
|
|
222
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
dbu_value = quote(
|
|
93
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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 = [
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_keep_alive_scope/test.sh
RENAMED
|
File without changes
|
|
File without changes
|
{google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_plot_redirection/test.sh
RENAMED
|
File without changes
|
|
File without changes
|
{google_colab_cli-0.5.6 → google_colab_cli-0.5.7}/integration/repro_variable_persistence/test.sh
RENAMED
|
File without changes
|
|
@@ -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
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|