google-colab-cli 0.5.8__tar.gz → 0.5.9__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 (76) hide show
  1. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/PKG-INFO +1 -1
  2. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/02_execution_and_interactive.md +1 -0
  3. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/04_automation_and_utility.md +4 -3
  4. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/05_run_command.md +1 -0
  5. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/README.md +2 -0
  6. google_colab_cli-0.5.9/integration/repro_bundled_oauth/test.sh +93 -0
  7. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/auth.py +41 -1
  8. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/cli.py +1 -1
  9. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/execution.py +6 -1
  10. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/run.py +8 -1
  11. google_colab_cli-0.5.9/src/colab_cli/oauth_config.json +11 -0
  12. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_auth.py +30 -2
  13. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_auth_adc.py +5 -6
  14. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_exec.py +23 -2
  15. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_ipynb_exec.py +2 -2
  16. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_run.py +25 -0
  17. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/.githooks/pre-commit +0 -0
  18. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/.gitignore +0 -0
  19. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/.pre-commit-config.yaml +0 -0
  20. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/.python-version +0 -0
  21. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/AGENTS.md +0 -0
  22. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/COLAB_SKILL.md +0 -0
  23. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/CONTRIBUTING.md +0 -0
  24. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/LICENSE +0 -0
  25. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/README.md +0 -0
  26. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/cloudbuild.yaml +0 -0
  27. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/01_session_management.md +0 -0
  28. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/03_file_management.md +0 -0
  29. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/3042ab12-2026-05-07.png +0 -0
  30. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/docs/demos.md +0 -0
  31. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_keep_alive/test.sh +0 -0
  32. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_keep_alive_scope/test.sh +0 -0
  33. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_piped_console/test.sh +0 -0
  34. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_plot_redirection/test.sh +0 -0
  35. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_run_command/test.sh +0 -0
  36. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/integration/repro_variable_persistence/test.sh +0 -0
  37. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/pyproject.toml +0 -0
  38. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/auto_update.py +0 -0
  39. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/client.py +0 -0
  40. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/__init__.py +0 -0
  41. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/automation.py +0 -0
  42. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/files.py +0 -0
  43. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/session.py +0 -0
  44. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/commands/utility.py +0 -0
  45. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/common.py +0 -0
  46. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/console.py +0 -0
  47. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/contents.py +0 -0
  48. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/converter.py +0 -0
  49. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/history.py +0 -0
  50. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/repl.py +0 -0
  51. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/runtime.py +0 -0
  52. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/state.py +0 -0
  53. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/src/colab_cli/utils.py +0 -0
  54. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/conftest.py +0 -0
  55. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_automation.py +0 -0
  56. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_cli.py +0 -0
  57. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_cli_log.py +0 -0
  58. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_client.py +0 -0
  59. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_console.py +0 -0
  60. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_contents.py +0 -0
  61. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_history.py +0 -0
  62. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_keep_alive.py +0 -0
  63. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_log_export.py +0 -0
  64. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_pay.py +0 -0
  65. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_readme.py +0 -0
  66. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_repl.py +0 -0
  67. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_resolution_logic.py +0 -0
  68. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_runtime.py +0 -0
  69. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_state.py +0 -0
  70. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_streaming.py +0 -0
  71. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_update.py +0 -0
  72. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_url.py +0 -0
  73. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_utils.py +0 -0
  74. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_version.py +0 -0
  75. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/tests/test_whoami.py +0 -0
  76. {google_colab_cli-0.5.8 → google_colab_cli-0.5.9}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-colab-cli
3
- Version: 0.5.8
3
+ Version: 0.5.9
4
4
  Summary: CLI for interacting with Colab.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -26,6 +26,7 @@ Execution involves sending Python code (or shell commands) to the Jupyter kernel
26
26
  - If file path is local: Read content, send as code.
27
27
  - If file path is remote: Execute `!python <path>`.
28
28
  - **Multi-Modal Output**: Handle `display_data` messages (e.g., `image/png`, `text/html`). For the CLI, we'll save images to temporary files and print their paths, or if the terminal supports it (e.g., iTerm2), inline them.
29
+ - **Timeout Configuration**: Exposes a `--timeout` flag (default 10s) to allow long-running silent tasks (like model compilation or data downloading) to execute without being prematurely killed.
29
30
 
30
31
  ### 3. Console (`colab console`)
31
32
  - **Implementation**: Connects directly to the backend terminal endpoint (`/colab/tty`) via WebSockets using `websocket-client`.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  log:
3
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-29: Added default OAuth2 client config (`oauth_config.json`) as a bundled package resource and restored fallback loading logic in `get_credentials()`. The CLI now falls back to using these default credentials when no explicit local config is found. Added `integration/repro_bundled_oauth` integration test.
4
5
  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
6
  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
7
  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.
@@ -24,9 +25,9 @@ backend, selected via the global `--auth=<provider>` flag:
24
25
 
25
26
  1. **`oauth2`** (default): Standard public InstalledAppFlow via
26
27
  `google-auth-oauthlib`. Opens a browser for consent, caches the refresh
27
- token at `~/.config/colab-cli/token.json`. Requires a client OAuth
28
- config at `~/.colab-cli-oauth-config.json` or a path passed via
29
- `-c/--client-oauth-config`.
28
+ token at `~/.config/colab-cli/token.json`. If no local config is provided
29
+ via `-c/--client-oauth-config` or found at `~/.colab-cli-oauth-config.json`,
30
+ it falls back to a bundled `oauth_config.json` containing default OAuth credentials.
30
31
  2. **`adc`**: Application Default Credentials via `google.auth.default()`.
31
32
  Honors the standard ADC discovery chain
32
33
  (`GOOGLE_APPLICATION_CREDENTIALS`, `gcloud auth application-default
@@ -29,6 +29,7 @@ colab run [OPTIONS] SCRIPT [SCRIPT_ARGS]...
29
29
  | `--gpu` | str | None | Same set as `colab new --gpu` (T4, L4, G4, H100, A100). |
30
30
  | `--tpu` | str | None | Same set as `colab new --tpu` (v5e1, v6e1). |
31
31
  | `--keep` | bool | False | Do **not** stop the session after the script finishes. |
32
+ | `--timeout` | float | 10.0 | Timeout in seconds for code execution to prevent hanging on silent tasks. |
32
33
 
33
34
  ### Shebang usage
34
35
  With `--keep` and `--gpu` baked into the shebang line, an entire one-file workload becomes:
@@ -16,6 +16,8 @@ End-to-end tests that run against a **live Colab backend** (unlike the mocked un
16
16
  | `repro_keep_alive_scope/` | Slow soak test (~95s): runs the daemon long enough for one ping past the pre-flight, asserts no `keep_alive_error` events. |
17
17
  | `repro_variable_persistence/` | Variables persist across `colab exec` calls in the same session. |
18
18
  | `repro_piped_console/` | Fast smoke test (~5s including session creation): `echo cmd \| colab console -s s` runs the command and exits within 30s. Regression test for the 2026-05-07 EOF-handler fix. |
19
+ | `repro_bundled_oauth/` | Fast smoke test (~5s): verifies that the fallback OAuth configuration is loaded and starts the OAuth flow with the default client ID when local config is missing. |
20
+
19
21
 
20
22
  ## Running
21
23
  ```bash
@@ -0,0 +1,93 @@
1
+ #!/bin/bash
2
+ # Copyright 2026 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # Integration Test: Fallback OAuth Configuration Verification
17
+ #
18
+ # This test verifies that the CLI correctly falls back to using the bundled
19
+ # oauth_config.json containing the default client ID when no local config is present.
20
+ # It simulates a fresh user flow by temporarily hiding existing tokens/configs,
21
+ # starting the OAuth flow, and asserting that the printed authorization URL
22
+ # contains the correct Client ID.
23
+ #
24
+
25
+ set -e
26
+
27
+ TOKEN_PATH="$HOME/.config/colab-cli/token.json"
28
+ CONFIG_PATH="$HOME/.colab-cli-oauth-config.json"
29
+ BACKUP_SUFFIX=".backup.$(date +%s)"
30
+
31
+ TOKEN_BACKUP=""
32
+ CONFIG_BACKUP=""
33
+
34
+ cleanup() {
35
+ echo "[*] Cleaning up backups..."
36
+ if [ -n "$TOKEN_BACKUP" ] && [ -f "$TOKEN_BACKUP" ]; then
37
+ mv "$TOKEN_BACKUP" "$TOKEN_PATH"
38
+ echo "[*] Restored token.json"
39
+ fi
40
+ if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then
41
+ mv "$CONFIG_BACKUP" "$CONFIG_PATH"
42
+ echo "[*] Restored .colab-cli-oauth-config.json"
43
+ fi
44
+ }
45
+ trap cleanup EXIT
46
+
47
+ # 1. Back up existing configuration files if they exist
48
+ if [ -f "$TOKEN_PATH" ]; then
49
+ TOKEN_BACKUP="${TOKEN_PATH}${BACKUP_SUFFIX}"
50
+ mv "$TOKEN_PATH" "$TOKEN_BACKUP"
51
+ echo "[*] Temporarily backed up token.json to $TOKEN_BACKUP"
52
+ fi
53
+
54
+ if [ -f "$CONFIG_PATH" ]; then
55
+ CONFIG_BACKUP="${CONFIG_PATH}${BACKUP_SUFFIX}"
56
+ mv "$CONFIG_PATH" "$CONFIG_BACKUP"
57
+ echo "[*] Temporarily backed up .colab-cli-oauth-config.json to $CONFIG_BACKUP"
58
+ fi
59
+
60
+ # 2. Run colab sessions command to trigger the OAuth flow
61
+ echo "[*] Running 'colab --auth=oauth2 sessions' (expecting to trigger browser flow)..."
62
+ # We expect the command to block waiting for authorization, so we run it with a timeout.
63
+ # We redirect output to a file so we can inspect it.
64
+ OUTPUT_LOG=$(mktemp)
65
+ set +e
66
+ PYTHONUNBUFFERED=1 timeout 5 uv run colab --auth=oauth2 sessions > "$OUTPUT_LOG" 2>&1
67
+ EXIT_CODE=$?
68
+ set -e
69
+
70
+ echo "[*] Command exited with code: $EXIT_CODE"
71
+ echo "----------------- CLI Output -----------------"
72
+ cat "$OUTPUT_LOG"
73
+ echo "----------------------------------------------"
74
+
75
+ # 3. Assertions
76
+ EXPECTED_CLIENT_ID="764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
77
+
78
+ # The command should have printed the authorization URL
79
+ if ! grep -q "Please visit this URL to authorize this application" "$OUTPUT_LOG"; then
80
+ echo "[FAILURE] OAuth prompt message not found in output."
81
+ rm -f "$OUTPUT_LOG"
82
+ exit 1
83
+ fi
84
+
85
+ # The URL should contain the correct client ID
86
+ if ! grep -q "client_id=$EXPECTED_CLIENT_ID" "$OUTPUT_LOG"; then
87
+ echo "[FAILURE] Authorization URL does not contain the expected client ID: $EXPECTED_CLIENT_ID"
88
+ rm -f "$OUTPUT_LOG"
89
+ exit 1
90
+ fi
91
+
92
+ echo "[SUCCESS] Verified that CLI correctly initiated OAuth flow with the fallback Client ID: $EXPECTED_CLIENT_ID"
93
+ rm -f "$OUTPUT_LOG"
@@ -15,11 +15,13 @@
15
15
  import enum
16
16
  import json
17
17
  import logging
18
+ from importlib import resources
18
19
  import os
19
20
  import warnings
20
21
  from typing import Optional
21
22
 
22
23
  import google.auth
24
+ import typer
23
25
  from google.auth.transport import requests
24
26
  from google.auth.transport.requests import Request
25
27
  from google.oauth2.credentials import Credentials
@@ -43,6 +45,7 @@ PUBLIC_SCOPES = [
43
45
  "openid",
44
46
  "https://www.googleapis.com/auth/userinfo.profile",
45
47
  "https://www.googleapis.com/auth/userinfo.email",
48
+ "https://www.googleapis.com/auth/cloud-platform",
46
49
  "https://www.googleapis.com/auth/colaboratory",
47
50
  "https://www.googleapis.com/auth/drive.file",
48
51
  ]
@@ -60,9 +63,18 @@ def _get_google_auth_credentials(config_path: str) -> Credentials:
60
63
  if os.path.exists(config_path):
61
64
  with open(config_path, "r") as f:
62
65
  client_config = json.load(f)
66
+ else:
67
+ # Last resort: try inlined config
68
+ try:
69
+ config_resource = resources.files("colab_cli").joinpath("oauth_config.json")
70
+ if config_resource.is_file():
71
+ client_config = json.loads(config_resource.read_text())
72
+ except Exception as e:
73
+ logger.debug(f"Failed to load inlined config: {e}")
74
+
63
75
  if not client_config:
64
76
  raise FileNotFoundError(
65
- f"Client OAuth config not found at {config_path}. "
77
+ f"Client OAuth config not found at {config_path} and no inlined config available. "
66
78
  "Please provide a valid path via -c/--client-oauth-config."
67
79
  )
68
80
 
@@ -137,6 +149,34 @@ def _get_adc_credentials() -> Credentials:
137
149
  category=UserWarning,
138
150
  )
139
151
  creds, _ = google.auth.default(scopes=list(PUBLIC_SCOPES))
152
+
153
+
154
+ if not creds.valid:
155
+ from google.auth import compute_engine
156
+
157
+ if isinstance(creds, compute_engine.Credentials):
158
+ creds = None
159
+ else:
160
+ logger.warning("Failed to obtain valid ADC credentials.")
161
+ try:
162
+ logger.warning("Trying to refresh ADC credentials")
163
+ creds.refresh(Request())
164
+ except Exception as e:
165
+ logger.warning(f"Failed to refresh token: {e}")
166
+ creds = None
167
+
168
+ if not creds:
169
+ typer.echo(
170
+ "No valid default credentials found. To authenticate, run:\n\n"
171
+ " gcloud auth application-default login \\\n"
172
+ " --scopes=openid,"
173
+ "https://www.googleapis.com/auth/cloud-platform,"
174
+ "https://www.googleapis.com/auth/userinfo.email,"
175
+ "https://www.googleapis.com/auth/colaboratory\n",
176
+ err=True,
177
+ )
178
+ exit(1)
179
+
140
180
  # Some credential subclasses ignore the `scopes=` kwarg in `default()`
141
181
  # (e.g. user creds), so re-apply via `with_scopes` when supported.
142
182
  if getattr(creds, "requires_scopes", False):
@@ -75,7 +75,7 @@ def callback(
75
75
  ),
76
76
  case_sensitive=False,
77
77
  ),
78
- ] = AuthProvider.ADC,
78
+ ] = AuthProvider.OAUTH2,
79
79
  ):
80
80
  """
81
81
  Colab CLI global configuration.
@@ -108,6 +108,9 @@ def exec_command(
108
108
  output_image: Annotated[
109
109
  Optional[str], typer.Option("--output-image", help="Path to save plot")
110
110
  ] = None,
111
+ timeout: Annotated[
112
+ Optional[float], typer.Option("--timeout", help="Timeout in seconds for code execution")
113
+ ] = 10.0,
111
114
  ):
112
115
  """Execute code in a session"""
113
116
  from colab_cli.common import state
@@ -205,7 +208,9 @@ def exec_command(
205
208
  state.store.add(s)
206
209
 
207
210
  outputs = runtime.execute_code(
208
- code, output_hook=lambda o: display_output(o, output_image)
211
+ code,
212
+ output_hook=lambda o: display_output(o, output_image),
213
+ timeout=timeout
209
214
  )
210
215
  if "cell" in block:
211
216
  save_output(outputs, block["cell"])
@@ -233,6 +233,9 @@ def run_command(
233
233
  ),
234
234
  ),
235
235
  ] = False,
236
+ timeout: Annotated[
237
+ Optional[float], typer.Option("--timeout", help="Timeout in seconds for code execution")
238
+ ] = 10.0,
236
239
  ):
237
240
  """Run a Python script on a fresh Colab VM, then release the VM
238
241
 
@@ -386,7 +389,11 @@ def run_command(
386
389
  state.store.add(s)
387
390
 
388
391
  try:
389
- outputs = runtime.execute_code(payload, output_hook=_make_run_output_hook())
392
+ outputs = runtime.execute_code(
393
+ payload,
394
+ output_hook=_make_run_output_hook(),
395
+ timeout=timeout
396
+ )
390
397
  except Exception:
391
398
  # Genuine transport-level failure. Cleanup still happens via the
392
399
  # outer finally; surface non-zero exit so callers/CI notice.
@@ -0,0 +1,11 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
4
+ "project_id": "colab-cli",
5
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
6
+ "token_uri": "https://oauth2.googleapis.com/token",
7
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
8
+ "client_secret": "d-FL95Q19q7MQmFpd7hHD0Ty",
9
+ "redirect_uris": ["http://localhost"]
10
+ }
11
+ }
@@ -27,9 +27,11 @@ def mock_deps(mocker):
27
27
  m_flow_cls = mocker.patch("colab_cli.auth.InstalledAppFlow")
28
28
  m_request = mocker.patch("colab_cli.auth.Request")
29
29
  m_session = mocker.patch("colab_cli.auth.requests.AuthorizedSession")
30
+ m_resources = mocker.patch("colab_cli.auth.resources")
30
31
 
31
- # By default, pretend oauth config doesn't exist
32
+ # By default, pretend oauth config doesn't exist anywhere
32
33
  m_exists.return_value = False
34
+ m_resources.files.return_value.joinpath.return_value.is_file.return_value = False
33
35
 
34
36
  return {
35
37
  "exists": m_exists,
@@ -38,11 +40,15 @@ def mock_deps(mocker):
38
40
  "flow_cls": m_flow_cls,
39
41
  "request": m_request,
40
42
  "session": m_session,
43
+ "resources": m_resources,
41
44
  }
42
45
 
43
46
 
44
47
  def test_get_credentials_no_config(mock_deps):
45
- with pytest.raises(FileNotFoundError, match="Client OAuth config not found"):
48
+ with pytest.raises(
49
+ FileNotFoundError,
50
+ match="Client OAuth config not found.*and no inlined config available",
51
+ ):
46
52
  get_credentials("missing_config.json", provider=AuthProvider.OAUTH2)
47
53
 
48
54
 
@@ -105,3 +111,25 @@ def test_get_credentials_no_token(mock_deps):
105
111
 
106
112
  mock_deps["flow_cls"].from_client_config.assert_called_once()
107
113
  mock_flow.run_local_server.assert_called_once()
114
+
115
+
116
+ def test_get_credentials_fallback_config(mock_deps):
117
+ # Setup: config_path doesn't exist, but fallback file does
118
+ mock_deps["exists"].return_value = False
119
+ m_file = mock_deps["resources"].files.return_value.joinpath.return_value
120
+ m_file.is_file.return_value = True
121
+ m_file.read_text.return_value = '{"installed":{"client_id":"fallback_id"}}'
122
+
123
+ # Valid creds in token
124
+ mock_deps["exists"].side_effect = lambda path: path == TOKEN_CONFIG_PATH
125
+ mock_creds = MagicMock()
126
+ mock_creds.valid = True
127
+ mock_deps["creds_cls"].from_authorized_user_file.return_value = mock_creds
128
+
129
+ res = get_credentials("missing_config.json", provider=AuthProvider.OAUTH2)
130
+
131
+ mock_deps["resources"].files.assert_called_once_with("colab_cli")
132
+ m_file.is_file.assert_called_once()
133
+ m_file.read_text.assert_called_once()
134
+ mock_deps["creds_cls"].from_authorized_user_file.assert_called_once()
135
+ assert res == mock_deps["session"].return_value
@@ -70,10 +70,8 @@ def test_get_credentials_adc_does_not_invoke_other_providers(mocker):
70
70
  mock_flow.from_client_config.assert_not_called()
71
71
 
72
72
 
73
- def test_get_credentials_adc_requests_colaboratory_scope(mocker):
74
- """The RuntimeService at colab.pa.googleapis.com requires the
75
- `colaboratory` scope. ADC must request it via google.auth.default().
76
- """
73
+ def test_get_credentials_adc_requests_required_scopes(mocker):
74
+ """ADC must request required scopes via google.auth.default()."""
77
75
  mock_creds = MagicMock()
78
76
  # Pretend creds don't need re-scoping (e.g., user creds from gcloud).
79
77
  mock_creds.requires_scopes = False
@@ -87,12 +85,13 @@ def test_get_credentials_adc_requests_colaboratory_scope(mocker):
87
85
  scopes = mock_default.call_args.kwargs.get("scopes")
88
86
  assert scopes is not None, "google.auth.default() must be called with scopes="
89
87
  assert "https://www.googleapis.com/auth/colaboratory" in scopes
88
+ assert "https://www.googleapis.com/auth/cloud-platform" in scopes
90
89
  assert "https://www.googleapis.com/auth/userinfo.email" in scopes
91
90
 
92
91
 
93
92
  def test_get_credentials_adc_reapplies_scopes_for_scopable_creds(mocker):
94
93
  """For credential subclasses that support `with_scopes` (service accounts,
95
- GCE/GKE, etc.), we must call it so the colaboratory scope sticks even if
94
+ GCE/GKE, etc.), we must call it so the scopes stick even if
96
95
  google.auth.default() ignored the kwarg.
97
96
  """
98
97
  rescoped = MagicMock(name="rescoped_creds")
@@ -106,7 +105,7 @@ def test_get_credentials_adc_reapplies_scopes_for_scopable_creds(mocker):
106
105
 
107
106
  mock_creds.with_scopes.assert_called_once()
108
107
  applied_scopes = mock_creds.with_scopes.call_args.args[0]
109
- assert "https://www.googleapis.com/auth/colaboratory" in applied_scopes
108
+ assert "https://www.googleapis.com/auth/cloud-platform" in applied_scopes
110
109
  # The session is built from the *rescoped* creds, not the original.
111
110
  mock_session_cls.assert_called_once_with(rescoped)
112
111
 
@@ -58,7 +58,7 @@ def test_cli_exec_file(mock_store, mock_runtime_class, mock_common_state, tmp_pa
58
58
  mock_runtime.execute_code.assert_any_call(
59
59
  "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
60
60
  )
61
- mock_runtime.execute_code.assert_any_call("print('hello')", output_hook=ANY)
61
+ mock_runtime.execute_code.assert_any_call("print('hello')", output_hook=ANY, timeout=10.0)
62
62
 
63
63
 
64
64
  def test_cli_exec_stdin(mock_store, mock_runtime_class, mock_common_state):
@@ -80,7 +80,7 @@ def test_cli_exec_stdin(mock_store, mock_runtime_class, mock_common_state):
80
80
  assert mock_session.last_execution[1] is None
81
81
  assert mock_session.last_execution[2] is not None
82
82
  mock_store.add.assert_called_with(mock_session)
83
- mock_runtime.execute_code.assert_any_call("print(42)", output_hook=ANY)
83
+ mock_runtime.execute_code.assert_any_call("print(42)", output_hook=ANY, timeout=10.0)
84
84
 
85
85
 
86
86
  def test_cli_exec_not_found(mock_common_state):
@@ -176,3 +176,24 @@ def test_cli_exec_lost_session_prunes(
176
176
  assert result.exit_code == 1
177
177
  assert "appears to be lost" in result.output
178
178
  mock_common_state.prune_session.assert_called_once_with("lost-sess")
179
+
180
+
181
+ def test_cli_exec_timeout(mock_store, mock_runtime_class, mock_common_state, tmp_path):
182
+ mock_session = MagicMock()
183
+ mock_session.url = "http://url"
184
+ mock_session.token = "token123"
185
+ mock_session.name = "s1"
186
+ mock_session.kernel_id = None
187
+ mock_session.session_id = None
188
+ mock_store.get.return_value = mock_session
189
+
190
+ mock_common_state.resolve_session.return_value = "s1"
191
+ mock_runtime = mock_runtime_class.return_value
192
+ mock_runtime.execute_code.return_value = [{"text": "hello\n"}]
193
+
194
+ script = tmp_path / "script.py"
195
+ script.write_text("print('hello')")
196
+
197
+ result = runner.invoke(app, ["exec", "-s", "s1", "-f", str(script), "--timeout", "3600"])
198
+ assert result.exit_code == 0
199
+ mock_runtime.execute_code.assert_any_call("print('hello')", output_hook=ANY, timeout=3600.0)
@@ -98,10 +98,10 @@ class TestIpynbExec(unittest.TestCase):
98
98
  "os.chdir", mock_runtime.execute_code.call_args_list[0].args[0]
99
99
  )
100
100
  mock_runtime.execute_code.assert_any_call(
101
- "print('cell 1')", output_hook=ANY
101
+ "print('cell 1')", output_hook=ANY, timeout=10.0
102
102
  )
103
103
  mock_runtime.execute_code.assert_any_call(
104
- "print('cell 2')", output_hook=ANY
104
+ "print('cell 2')", output_hook=ANY, timeout=10.0
105
105
  )
106
106
 
107
107
  @patch("colab_cli.commands.execution.ColabRuntime")
@@ -504,3 +504,28 @@ def test_run_prelude_suppresses_ipython_exit_warning(
504
504
  # Look for the warnings filter targeting IPython's exit-warning text.
505
505
  assert "warnings.filterwarnings" in body
506
506
  assert "To exit: use" in body
507
+
508
+
509
+ def test_run_with_timeout_flag(
510
+ mock_client,
511
+ mock_store,
512
+ mock_runtime_class,
513
+ mock_spawn_keep_alive,
514
+ assign_response,
515
+ script_path,
516
+ ):
517
+ """`colab run --timeout 3600 script.py` must pass timeout down to the runtime."""
518
+ mock_client.assign.return_value = assign_response
519
+ mock_runtime = mock_runtime_class.return_value
520
+ mock_runtime.execute_code.return_value = []
521
+
522
+ persisted = {}
523
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
524
+ mock_store.get.side_effect = lambda name: persisted.get("s")
525
+
526
+ result = runner.invoke(app, ["run", "--timeout", "3600", str(script_path)])
527
+ assert result.exit_code == 0, result.output
528
+
529
+ code_calls = mock_runtime.execute_code.call_args_list
530
+ body_call = next(c for c in code_calls if "hello from script" in c.args[0])
531
+ assert body_call.kwargs.get("timeout") == 3600.0