google-colab-cli 0.5.4__py3-none-any.whl

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.
colab_cli/auth.py ADDED
@@ -0,0 +1,170 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import enum
16
+ import json
17
+ import logging
18
+ import os
19
+ import warnings
20
+ from typing import Optional
21
+
22
+ import google.auth
23
+ from google.auth.transport import requests
24
+ from google.auth.transport.requests import Request
25
+ from google.oauth2.credentials import Credentials
26
+ from google_auth_oauthlib.flow import InstalledAppFlow
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class AuthProvider(str, enum.Enum):
32
+ """Authentication strategy for talking to the Colab backend.
33
+
34
+ Values are the lowercase strings accepted by the global ``--auth`` flag.
35
+ """
36
+
37
+ OAUTH2 = "oauth2"
38
+ ADC = "adc"
39
+
40
+
41
+ # Standard Scopes for Colab and Drive (Public Auth)
42
+ PUBLIC_SCOPES = [
43
+ "openid",
44
+ "https://www.googleapis.com/auth/userinfo.profile",
45
+ "https://www.googleapis.com/auth/userinfo.email",
46
+ "https://www.googleapis.com/auth/colaboratory",
47
+ "https://www.googleapis.com/auth/drive.file",
48
+ ]
49
+
50
+
51
+ TOKEN_CONFIG_PATH = os.path.expanduser("~/.config/colab-cli/token.json")
52
+ OAUTH_SERVER_PORT = 8200
53
+
54
+
55
+ def _get_google_auth_credentials(config_path: str) -> Credentials:
56
+ """
57
+ Retrieves credentials using standard public OAuth2 flow.
58
+ """
59
+ client_config = None
60
+ if os.path.exists(config_path):
61
+ with open(config_path, "r") as f:
62
+ client_config = json.load(f)
63
+ if not client_config:
64
+ raise FileNotFoundError(
65
+ f"Client OAuth config not found at {config_path}. "
66
+ "Please provide a valid path via -c/--client-oauth-config."
67
+ )
68
+
69
+ creds = None
70
+
71
+ # Ensure config directory exists for the token file
72
+ os.makedirs(os.path.dirname(TOKEN_CONFIG_PATH), exist_ok=True)
73
+
74
+ if os.path.exists(TOKEN_CONFIG_PATH):
75
+ try:
76
+ creds = Credentials.from_authorized_user_file(
77
+ TOKEN_CONFIG_PATH, PUBLIC_SCOPES
78
+ )
79
+ except Exception as e:
80
+ logger.warning(f"Failed to load token from {TOKEN_CONFIG_PATH}: {e}")
81
+
82
+ if not creds or not creds.valid:
83
+ if creds and creds.expired and creds.refresh_token:
84
+ try:
85
+ creds.refresh(Request())
86
+ except Exception as e:
87
+ logger.warning(f"Failed to refresh token: {e}")
88
+ creds = None
89
+
90
+ if not creds:
91
+ flow = InstalledAppFlow.from_client_config(client_config, PUBLIC_SCOPES)
92
+ creds = flow.run_local_server(port=OAUTH_SERVER_PORT)
93
+
94
+ # Save the credentials for the next run
95
+ try:
96
+ with open(TOKEN_CONFIG_PATH, "w") as token_file:
97
+ token_file.write(creds.to_json())
98
+ except Exception as e:
99
+ logger.error(f"Failed to save token to {TOKEN_CONFIG_PATH}: {e}")
100
+
101
+ return creds
102
+
103
+
104
+ def _get_adc_credentials() -> Credentials:
105
+ """Retrieves credentials using Google Application Default Credentials.
106
+
107
+ Honors the standard ADC discovery chain (``GOOGLE_APPLICATION_CREDENTIALS``,
108
+ ``gcloud auth application-default login``, GCE/GKE metadata server, etc.).
109
+
110
+ The RuntimeService at colab.pa.googleapis.com requires the
111
+ `colaboratory` scope (otherwise keep-alive returns 403 SCOPE_NOT_PERMITTED).
112
+ Most ADC credential types (service accounts, GCE/GKE, impersonated)
113
+ support `with_scopes`; user credentials minted by
114
+ `gcloud auth application-default login` do not. For the latter, the user
115
+ must re-run `gcloud auth application-default login` with
116
+ `--scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`
117
+ (`openid` and `cloud-platform` are required by `gcloud` itself; `userinfo.email`
118
+ is required by the session backend; `colaboratory` is required by this RPC).
119
+ """
120
+ # `google.auth._default` emits a UserWarning when ADC user credentials
121
+ # don't have a quota project pinned ("Your application has authenticated
122
+ # using end user credentials from Google Cloud SDK without a quota
123
+ # project. You might receive a 'quota exceeded' or 'API not enabled'
124
+ # error.").
125
+ #
126
+ # That heuristic does not apply to this CLI: every call we make to
127
+ # `colab.pa.googleapis.com` carries `X-Goog-User-Project: 1014160490159`
128
+ # (Colab's project id) — see AGENTS.md item 18 — so the user's
129
+ # quota-project setting is irrelevant. The warning shows up on every
130
+ # single `colab` invocation under ADC, which is pure noise. Filter it,
131
+ # but keep the scope as tight as possible: only this exact message,
132
+ # only during this one call.
133
+ with warnings.catch_warnings():
134
+ warnings.filterwarnings(
135
+ "ignore",
136
+ message=r"Your application has authenticated using end user credentials.*",
137
+ category=UserWarning,
138
+ )
139
+ creds, _ = google.auth.default(scopes=list(PUBLIC_SCOPES))
140
+ # Some credential subclasses ignore the `scopes=` kwarg in `default()`
141
+ # (e.g. user creds), so re-apply via `with_scopes` when supported.
142
+ if getattr(creds, "requires_scopes", False):
143
+ try:
144
+ creds = creds.with_scopes(list(PUBLIC_SCOPES))
145
+ except Exception as e: # NotImplementedError for non-scopable creds.
146
+ logger.debug(f"Could not augment ADC scopes via with_scopes: {e}")
147
+ return creds
148
+
149
+
150
+ def get_credentials(
151
+ config_path: Optional[str] = None,
152
+ provider: AuthProvider = AuthProvider.OAUTH2,
153
+ ) -> requests.AuthorizedSession:
154
+ """Unified entry point for retrieving an authorized session.
155
+
156
+ Args:
157
+ config_path: Path to the OAuth2 client config JSON. Only consulted when
158
+ ``provider`` is ``OAUTH2``.
159
+ provider: Which authentication strategy to use.
160
+ """
161
+ if provider == AuthProvider.OAUTH2:
162
+ if not config_path:
163
+ config_path = os.path.expanduser("~/.colab-cli-oauth-config.json")
164
+ creds = _get_google_auth_credentials(config_path)
165
+ elif provider == AuthProvider.ADC:
166
+ creds = _get_adc_credentials()
167
+ else:
168
+ raise ValueError(f"Unknown auth provider: {provider!r}")
169
+
170
+ return requests.AuthorizedSession(creds)
@@ -0,0 +1,226 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Auto-update subsystem.
16
+
17
+ Owns version detection, the PyPI-style update probe, the on-disk
18
+ ``latest_version`` cache, and the upgrade-banner UX. The CLI's global
19
+ callback (``cli.py``) calls ``check_for_updates`` once per day and
20
+ ``maybe_show_cached_banner`` on every other invocation; the
21
+ ``colab update`` Typer command (``commands/utility.py``) delegates to
22
+ ``check_for_updates``.
23
+ """
24
+
25
+ import json
26
+ import subprocess
27
+ import urllib.request
28
+ from datetime import datetime, timezone
29
+ from importlib.metadata import PackageNotFoundError, version as installed_version
30
+ from packaging.version import InvalidVersion, Version
31
+ from typing import Optional
32
+
33
+ import typer
34
+
35
+ from colab_cli.common import state
36
+ from colab_cli.state import Settings
37
+
38
+
39
+ # ---------- Version detection -------------------------------------------
40
+
41
+
42
+ def get_app_version() -> str:
43
+ """Return the installed package version, falling back to the git short hash."""
44
+ try:
45
+ return installed_version("colab")
46
+ except (PackageNotFoundError, InvalidVersion):
47
+ pass
48
+
49
+ try:
50
+ return subprocess.check_output(
51
+ ["git", "rev-parse", "--short", "HEAD"],
52
+ stderr=subprocess.DEVNULL,
53
+ encoding="utf-8",
54
+ ).strip()
55
+ except Exception:
56
+ return "unknown"
57
+
58
+
59
+ # ---------- Source fetchers ---------------------------------------------
60
+
61
+
62
+ def _parse_version(payload: Optional[dict]) -> Optional[str]:
63
+ """Returns ``info.version`` from a PyPI-style payload, or None."""
64
+ return (payload or {}).get("info", {}).get("version")
65
+
66
+
67
+ def _fetch_pypi(url: str, quiet: bool) -> Optional[dict]:
68
+ """Fetches and parses the PyPI-style JSON document at ``url``."""
69
+ try:
70
+ with urllib.request.urlopen(url, timeout=5) as response:
71
+ return json.loads(response.read().decode("utf-8"))
72
+ except Exception as e:
73
+ if not quiet:
74
+ typer.echo(f"[colab] Warning: Failed to fetch update info: {e}")
75
+ return None
76
+
77
+
78
+ # ---------- Version comparison ------------------------------------------
79
+
80
+
81
+ def _is_newer(candidate: Optional[str], current: str) -> bool:
82
+ """True when ``candidate`` strictly exceeds ``current`` (PEP 440)."""
83
+ if not candidate:
84
+ return False
85
+ try:
86
+ return Version(candidate) > Version(current)
87
+ except InvalidVersion:
88
+ return candidate != current
89
+
90
+
91
+ # ---------- UX ----------------------------------------------------------
92
+
93
+
94
+ def announce_upgrade(
95
+ latest: str,
96
+ current: str,
97
+ install_cmd: str,
98
+ *,
99
+ show_disable_hint: bool = False,
100
+ ) -> None:
101
+ """Print the upgrade banner.
102
+
103
+ ``show_disable_hint`` controls whether the trailing line that explains
104
+ how to silence the auto-check is included. It is only added when the
105
+ banner is shown unsolicited (the daily background fetch and the cached
106
+ banner on subsequent invocations); explicit ``colab update`` calls
107
+ omit it because the user already opted in to seeing the result.
108
+ """
109
+ typer.echo(
110
+ f"\n[colab] A new version of Colab CLI is available: {latest} (current: {current})"
111
+ )
112
+ typer.echo(f"[colab] Run '{install_cmd}' to update.")
113
+ if show_disable_hint:
114
+ typer.echo(
115
+ "[colab] To silence this check, set "
116
+ '"enable_update_check": false in '
117
+ "~/.config/colab-cli/settings.json"
118
+ )
119
+ typer.echo("")
120
+
121
+
122
+ # ---------- Orchestration -----------------------------------------------
123
+
124
+
125
+ def check_for_updates(quiet: bool = False) -> None:
126
+ """Check PyPI for updates and print a message if a new version is available.
127
+
128
+ The disable-hint is appended to the banner only when ``quiet`` is True
129
+ (the daily background fetch); explicit ``colab update`` invocations
130
+ (``quiet=False``) omit it because the user asked for the check.
131
+ """
132
+ settings = state.settings_store.load()
133
+ current = get_app_version()
134
+
135
+ try:
136
+ pypi = _fetch_pypi(settings.update_url, quiet)
137
+ pypi_v = _parse_version(pypi)
138
+
139
+ if _is_newer(pypi_v, current):
140
+ announce_upgrade(
141
+ pypi_v,
142
+ current,
143
+ "pip install --upgrade colab",
144
+ show_disable_hint=quiet,
145
+ )
146
+ elif not quiet:
147
+ suffix = f", latest: {pypi_v}" if pypi_v else ""
148
+ typer.echo(f"[colab] Colab CLI is up to date (version: {current}{suffix}).")
149
+
150
+ # Cache the highest observed version; never downgrade.
151
+ cached = settings.latest_version or "0"
152
+ if _is_newer(pypi_v, cached):
153
+ settings.latest_version = pypi_v
154
+
155
+ settings.last_check = datetime.now(timezone.utc)
156
+ state.settings_store.save(settings)
157
+
158
+ except Exception as e:
159
+ if not quiet:
160
+ typer.echo(f"[colab] Failed to check for updates: {e}")
161
+
162
+
163
+ # ---------- Background hooks (called from cli.py) -----------------------
164
+
165
+
166
+ def _is_throttled(settings: Settings, *, now: Optional[datetime] = None) -> bool:
167
+ """True if the once-per-day fetch should be skipped."""
168
+ if settings.last_check is None:
169
+ return False
170
+ now = now or datetime.now(timezone.utc)
171
+ return (now - settings.last_check).days < 1
172
+
173
+
174
+ def maybe_show_cached_banner(settings: Settings) -> None:
175
+ """Print the cached upgrade banner if the cache reports a newer version.
176
+
177
+ Called from the global CLI callback when the daily fetch is throttled.
178
+ The banner uses a generic ``colab update`` install hint because the
179
+ cache does not record which source supplied the version; the disable
180
+ hint is shown because this is unsolicited output.
181
+ """
182
+ if not settings.latest_version:
183
+ return
184
+ current = get_app_version()
185
+ if not _is_newer(settings.latest_version, current):
186
+ return
187
+ announce_upgrade(
188
+ settings.latest_version,
189
+ current,
190
+ "colab update",
191
+ show_disable_hint=True,
192
+ )
193
+
194
+
195
+ def run_background_check() -> None:
196
+ """Entry point for the global CLI callback.
197
+
198
+ Performs either the daily fetch (which writes the cache) or, if
199
+ throttled, surfaces the cached banner. Honors the
200
+ ``enable_update_check`` master switch.
201
+ """
202
+ settings = state.settings_store.load()
203
+ if not settings.enable_update_check:
204
+ return
205
+ if _is_throttled(settings):
206
+ maybe_show_cached_banner(settings)
207
+ else:
208
+ check_for_updates(quiet=True)
209
+
210
+
211
+ # ---------- Self-install ------------------------------------------------
212
+
213
+
214
+ # PyPI distribution name (different from the importable package name `colab`).
215
+ PYPI_PACKAGE_NAME = "google-colab-cli"
216
+
217
+
218
+ def self_install() -> None:
219
+ """Run ``pip install -U <PYPI_PACKAGE_NAME>`` to upgrade the CLI in place."""
220
+ import sys
221
+
222
+ cmd = [sys.executable, "-m", "pip", "install", "-U", PYPI_PACKAGE_NAME]
223
+ typer.echo(f"[colab] Running: {' '.join(cmd)}")
224
+ result = subprocess.run(cmd)
225
+ if result.returncode != 0:
226
+ raise typer.Exit(code=result.returncode)
colab_cli/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ from typing import Optional
17
+
18
+ import click
19
+ import typer
20
+ from typer.core import TyperGroup
21
+ from typing_extensions import Annotated
22
+
23
+ from colab_cli import auto_update
24
+ from colab_cli.auth import AuthProvider
25
+ from colab_cli.common import state, setup_logging
26
+ from colab_cli.commands import session, execution, files, automation, run, utility
27
+
28
+
29
+ class AlphabeticalGroup(TyperGroup):
30
+ """A `TyperGroup` that lists subcommands alphabetically in `--help` output.
31
+
32
+ Subcommands are registered in functional groups (session, execution, files,
33
+ automation, utility), but users discovering the CLI via `colab --help` /
34
+ `colab help` benefit from a deterministic, alphabetical listing.
35
+ """
36
+
37
+ def list_commands(self, ctx: click.Context) -> list[str]:
38
+ return sorted(super().list_commands(ctx))
39
+
40
+
41
+ app = typer.Typer(
42
+ help="Colab CLI",
43
+ no_args_is_help=True,
44
+ context_settings={"help_option_names": ["-h", "--help"]},
45
+ cls=AlphabeticalGroup,
46
+ )
47
+
48
+
49
+ @app.callback()
50
+ def callback(
51
+ ctx: typer.Context,
52
+ client_oauth_config: Annotated[
53
+ str,
54
+ typer.Option(
55
+ "-c", "--client-oauth-config", help="Path to client OAuth config JSON file"
56
+ ),
57
+ ] = os.path.expanduser("~/.colab-cli-oauth-config.json"),
58
+ config: Annotated[
59
+ Optional[str],
60
+ typer.Option(
61
+ "--config",
62
+ help="Path to session state file (~/.config/colab-cli/sessions.json)",
63
+ ),
64
+ ] = None,
65
+ logtostderr: Annotated[
66
+ bool, typer.Option("--logtostderr", help="Log all output to stderr")
67
+ ] = False,
68
+ auth: Annotated[
69
+ AuthProvider,
70
+ typer.Option(
71
+ "--auth",
72
+ help=(
73
+ "Authentication strategy to use: 'oauth2' (public InstalledAppFlow),"
74
+ " or 'adc' (Application Default Credentials)."
75
+ ),
76
+ case_sensitive=False,
77
+ ),
78
+ ] = AuthProvider.ADC,
79
+ ):
80
+ """
81
+ Colab CLI global configuration.
82
+ """
83
+ state.client_oauth_config = client_oauth_config
84
+ state.config_path = config
85
+ state.logtostderr = logtostderr
86
+ state.auth_provider = auth
87
+ setup_logging(logtostderr)
88
+
89
+ # Daily fetch + cached banner on every invocation.
90
+ #
91
+ # Suppress the banner for short-lived informational subcommands so their
92
+ # output stays clean and machine-parseable:
93
+ # - `update`: runs its own check + announce; would duplicate the banner.
94
+ # - `version`, `log`, `pay`, `help`, `url`: pure-display commands whose
95
+ # output users routinely pipe / scrape (e.g. `colab url -s s1 | xclip`);
96
+ # a stochastic upgrade banner injected once a day would corrupt those
97
+ # pipelines.
98
+ # - `whoami`: developer-only debugging tool; banner would obscure the
99
+ # auth/scope info the user invoked it to see.
100
+ _AUTO_UPDATE_SUPPRESSED = {
101
+ "update",
102
+ "version",
103
+ "log",
104
+ "pay",
105
+ "help",
106
+ "url",
107
+ "whoami",
108
+ }
109
+ if ctx.invoked_subcommand not in _AUTO_UPDATE_SUPPRESSED:
110
+ auto_update.run_background_check()
111
+
112
+
113
+ @app.command(name="help")
114
+ def help_command(
115
+ ctx: typer.Context,
116
+ command: Annotated[
117
+ Optional[str], typer.Argument(help="Command to show help for")
118
+ ] = None,
119
+ ):
120
+ """
121
+ Show help for a command.
122
+ """
123
+ if not command:
124
+ typer.echo(ctx.parent.get_help())
125
+ return
126
+
127
+ group = ctx.parent.command
128
+ cmd = group.get_command(ctx, command)
129
+ if cmd is None:
130
+ typer.echo(f"No such command '{command}'.", err=True)
131
+ raise typer.Exit(code=2)
132
+
133
+ with click.Context(cmd, info_name=command, parent=ctx.parent) as cmd_ctx:
134
+ typer.echo(cmd.get_help(cmd_ctx))
135
+
136
+
137
+ # Register subcommands
138
+ session.register(app)
139
+ execution.register(app)
140
+ files.register(app)
141
+ automation.register(app)
142
+ run.register(app)
143
+ utility.register(app)
144
+
145
+
146
+ def main():
147
+ app()
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()