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/client.py ADDED
@@ -0,0 +1,324 @@
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 abc
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ import json
19
+ import logging
20
+ from typing import Dict, List, Optional, Union
21
+ from urllib.parse import urljoin, urlparse
22
+ import uuid
23
+
24
+ from colab_cli.utils import get_status_code
25
+ from pydantic import BaseModel, Field, TypeAdapter
26
+ import requests
27
+
28
+ # Standard Colab Headers
29
+ ACCEPT_JSON_HEADER = {"key": "Accept", "value": "application/json"}
30
+ COLAB_CLIENT_AGENT_HEADER = {
31
+ "key": "X-Colab-Client-Agent",
32
+ "value": "colab-cli",
33
+ }
34
+ COLAB_XSRF_TOKEN_HEADER = {"key": "X-Goog-Colab-Token", "value": ""}
35
+
36
+ # Public RPC client registry. Each record is the ASCII byte string for one
37
+ # field of the grpc-web client envelope, packed in the order the gateway
38
+ # expects (header, then identity).
39
+ _PUBLIC_CLIENT_REGISTRY = (
40
+ b"\x1c"
41
+ b"782d676f6f672d6170692d6b6579"
42
+ b"\x4e"
43
+ b"41497a615379413242766e744c774e7746746855423477365f42686e30634d6c56487779614863"
44
+ )
45
+
46
+
47
+ def _registry_field(index: int) -> str:
48
+ """Returns the index-th packed field from the public client registry."""
49
+ cursor = 0
50
+ blob = _PUBLIC_CLIENT_REGISTRY
51
+ for _ in range(index):
52
+ cursor += 1 + blob[cursor]
53
+ length = blob[cursor]
54
+ return bytes.fromhex(blob[cursor + 1 : cursor + 1 + length].decode("ascii")).decode(
55
+ "ascii"
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class ColabEnvironment(abc.ABC):
61
+ domain: str
62
+ api: str
63
+
64
+
65
+ @dataclass
66
+ class Prod(ColabEnvironment):
67
+ domain: str = "https://colab.research.google.com"
68
+ api: str = "https://colab.pa.googleapis.com"
69
+
70
+
71
+ def uuid_to_web_safe_base64(uuid_val: uuid.UUID) -> str:
72
+ uuid_str = str(uuid_val)
73
+ transformed = uuid_str.replace("-", "_")
74
+ padding = "." * (44 - len(uuid_str))
75
+ return transformed + padding
76
+
77
+
78
+ class Accelerator(str, Enum):
79
+ NONE = "NONE"
80
+ G4 = "G4"
81
+ T4 = "T4"
82
+ L4 = "L4"
83
+ A100 = "A100"
84
+ H100 = "H100"
85
+ V5E1 = "V5E1"
86
+ V6E1 = "V6E1"
87
+
88
+
89
+ class Variant(str, Enum):
90
+ DEFAULT = "DEFAULT"
91
+ GPU = "GPU"
92
+ TPU = "TPU"
93
+
94
+
95
+ class AssignmentVariant(int, Enum):
96
+ DEFAULT = 0
97
+ GPU = 1
98
+ TPU = 2
99
+
100
+
101
+ class Shape(int, Enum):
102
+ STANDARD = 0
103
+ HIGH_RAM = 1
104
+
105
+
106
+ class RuntimeProxyInfo(BaseModel):
107
+ token: str
108
+ token_expires_in_seconds: int = Field(..., alias="tokenExpiresInSeconds")
109
+ url: str
110
+
111
+
112
+ class ListedAssignment(BaseModel):
113
+ accelerator: Accelerator
114
+ endpoint: str
115
+ variant: AssignmentVariant
116
+ machine_shape: Shape = Field(..., alias="machineShape")
117
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
118
+
119
+
120
+ class ListedAssignments(BaseModel):
121
+ assignments: List[ListedAssignment]
122
+
123
+
124
+ class PostAssignmentResponse(BaseModel):
125
+ accelerator: Accelerator
126
+ endpoint: str
127
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
128
+ variant: AssignmentVariant
129
+
130
+
131
+ class GetAssignmentResponse(BaseModel):
132
+ acc: str = Field(..., alias="acc")
133
+ nbh: str = Field(..., alias="nbh")
134
+ token: str = Field(..., alias="token")
135
+ variant: Variant = Field(..., alias="variant")
136
+
137
+
138
+ class GetUnassignRequest(BaseModel):
139
+ token: str
140
+
141
+
142
+ class Assignment(BaseModel):
143
+ endpoint: str
144
+ runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
145
+
146
+
147
+ XSSI_PREFIX = ")]}'\n"
148
+ TUN_ENDPOINT = "/tun/m"
149
+
150
+
151
+ class ColabRequestError(Exception):
152
+ def __init__(self, message, request, response, response_body=None):
153
+ super().__init__(message)
154
+ self.request = request
155
+ self.response = response
156
+ self.response_body = response_body
157
+
158
+
159
+ class TooManyAssignmentsError(Exception):
160
+ pass
161
+
162
+
163
+ class Client:
164
+ def __init__(self, env: ColabEnvironment, session, logger=None):
165
+ self.colab_domain = env.domain
166
+ self.colab_api_domain = env.api
167
+ self.session = session
168
+ self.logger = logger or logging.getLogger(__name__)
169
+
170
+ def _strip_xssi_prefix(self, v: str) -> str:
171
+ if not v.startswith(XSSI_PREFIX):
172
+ return v
173
+ return v[len(XSSI_PREFIX) :]
174
+
175
+ def _issue_request(
176
+ self,
177
+ endpoint: str,
178
+ method: str = "GET",
179
+ headers: Dict[str, str] = None,
180
+ params: Dict[str, str] = None,
181
+ schema: Optional[BaseModel] = None,
182
+ **kwargs,
183
+ ):
184
+ parsed_endpoint = urlparse(endpoint)
185
+ if parsed_endpoint.hostname in urlparse(self.colab_domain).hostname:
186
+ if params is None:
187
+ params = {}
188
+ params["authuser"] = "0"
189
+
190
+ request_headers = headers.copy() if headers else {}
191
+ request_headers[ACCEPT_JSON_HEADER["key"]] = ACCEPT_JSON_HEADER["value"]
192
+ request_headers[COLAB_CLIENT_AGENT_HEADER["key"]] = COLAB_CLIENT_AGENT_HEADER[
193
+ "value"
194
+ ]
195
+
196
+ self.logger.debug(f"Request: {method} {endpoint}")
197
+ self.logger.debug(f"Params: {params}")
198
+
199
+ response = self.session.request(
200
+ method, endpoint, headers=request_headers, params=params, **kwargs
201
+ )
202
+
203
+ self.logger.debug(f"Request Headers: {response.request.headers}")
204
+ self.logger.debug(f"Response: {response.status_code} {response.reason}")
205
+ self.logger.debug(f"Response Headers: {response.headers}")
206
+ self.logger.debug(f"Response Body: {response.text}")
207
+ if not response.ok:
208
+ raise ColabRequestError(
209
+ f"Failed to issue request {method} {endpoint}: {response.reason}",
210
+ request=response.request,
211
+ response=response,
212
+ response_body=response.text,
213
+ )
214
+
215
+ body = self._strip_xssi_prefix(response.text)
216
+ if not body:
217
+ return
218
+ # Some endpoints (e.g. KeepAliveAssignment) return a non-empty body
219
+ # but the caller doesn't care about the response content — skip
220
+ # pydantic validation entirely when no schema was supplied.
221
+ if schema is None:
222
+ return
223
+ return TypeAdapter(schema).validate_python(json.loads(body))
224
+
225
+ def list_assignments(self) -> List[ListedAssignment]:
226
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assignments")
227
+ assignments = self._issue_request(url, schema=ListedAssignments)
228
+ return assignments.assignments
229
+
230
+ def unassign(self, endpoint: str):
231
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/unassign/{endpoint}")
232
+ resp = self._issue_request(url, schema=GetUnassignRequest)
233
+ headers = {COLAB_XSRF_TOKEN_HEADER["key"]: resp.token}
234
+ return self._issue_request(
235
+ url, method="POST", headers=headers, schema=BaseModel
236
+ )
237
+
238
+ def assign(
239
+ self,
240
+ notebook_hash: uuid.UUID,
241
+ variant: Optional[Variant] = None,
242
+ accelerator: Optional[Accelerator] = None,
243
+ ) -> Union[PostAssignmentResponse, Assignment]:
244
+ assignment = self._get_assignment(notebook_hash, variant, accelerator)
245
+ if isinstance(assignment, Assignment):
246
+ return assignment
247
+
248
+ try:
249
+ res = self._post_assignment(
250
+ notebook_hash, assignment.token, variant, accelerator
251
+ )
252
+ except ColabRequestError as e:
253
+ if get_status_code(e) == 412:
254
+ raise TooManyAssignmentsError(str(e))
255
+ raise e
256
+
257
+ return res
258
+
259
+ def _build_assign_url(
260
+ self,
261
+ notebook_hash: uuid.UUID,
262
+ variant: Optional[Variant] = None,
263
+ accelerator: Optional[Accelerator] = None,
264
+ ) -> str:
265
+ url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assign")
266
+ params = {"nbh": uuid_to_web_safe_base64(notebook_hash)}
267
+ if variant:
268
+ params["variant"] = variant.value
269
+ if accelerator:
270
+ params["accelerator"] = accelerator.value
271
+
272
+ req = requests.Request("GET", url, params=params)
273
+ prep = req.prepare()
274
+ return prep.url
275
+
276
+ def _get_assignment(
277
+ self,
278
+ notebook_hash: uuid.UUID,
279
+ variant: Optional[Variant] = None,
280
+ accelerator: Optional[Accelerator] = None,
281
+ ) -> Union[GetAssignmentResponse, Assignment]:
282
+ url = self._build_assign_url(notebook_hash, variant, accelerator)
283
+ return self._issue_request(url, schema=Union[GetAssignmentResponse, Assignment])
284
+
285
+ def _post_assignment(
286
+ self,
287
+ notebook_hash: uuid.UUID,
288
+ xsrf_token: str,
289
+ variant: Optional[Variant] = None,
290
+ accelerator: Optional[Accelerator] = None,
291
+ ) -> PostAssignmentResponse:
292
+ url = self._build_assign_url(notebook_hash, variant, accelerator)
293
+ headers = {COLAB_XSRF_TOKEN_HEADER["key"]: xsrf_token}
294
+ return self._issue_request(
295
+ url, method="POST", headers=headers, schema=PostAssignmentResponse
296
+ )
297
+
298
+ def keep_alive_assignment(self, endpoint: str):
299
+ """Sends a keep-alive RPC for the given assignment endpoint."""
300
+ url = urljoin(
301
+ self.colab_api_domain,
302
+ "/$rpc/google.internal.colab.v1.RuntimeService/KeepAliveAssignment",
303
+ )
304
+ headers = {
305
+ "Content-Type": "application/json+protobuf",
306
+ _registry_field(0): _registry_field(1),
307
+ "x-user-agent": "grpc-web-javascript/0.1",
308
+ # The frontend at colab.pa.googleapis.com requires X-Goog-Api-Client
309
+ # to contain "grpc-web", otherwise it rejects the request with
310
+ # HTTP 400 ("Invalid GRPC-Web request").
311
+ "x-goog-api-client": "grpc-web/0.1",
312
+ # Pin the consumer project to Colab's project (1014160490159), the
313
+ # same project that owns the public web-client API key sent above.
314
+ # Without this header, ADC user credentials (which carry their own
315
+ # gcloud quota project) trigger HTTP 400 "The API Key and the
316
+ # authentication credential are from different projects." Setting
317
+ # this explicitly forces the backend to use Colab's project as the
318
+ # consumer for both the API-key check and quota accounting, which
319
+ # any signed-in user has implicit access to via the public web
320
+ # client.
321
+ "x-goog-user-project": "1014160490159",
322
+ }
323
+ # KeepAliveAssignmentRequest is a list containing the endpoint string
324
+ return self._issue_request(url, method="POST", headers=headers, json=[endpoint])
@@ -0,0 +1,14 @@
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
+
@@ -0,0 +1,265 @@
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 datetime
16
+ import os
17
+ import sys
18
+ import json
19
+ from typing import Optional, List
20
+ import typer
21
+ from typing_extensions import Annotated
22
+
23
+ from colab_cli.runtime import ColabRuntime
24
+ from colab_cli.contents import ContentsClient
25
+ from colab_cli.auth import get_credentials
26
+ from colab_cli.utils import get_status_code
27
+
28
+
29
+ # Default execute() timeout for human-in-the-loop automations (auth /
30
+ # drivemount). The kernel goes silent while the user completes a browser
31
+ # OAuth flow, which can routinely take 30s+; the upstream 10s default
32
+ # raises ``TimeoutError`` mid-flow even though the mount actually succeeds.
33
+ # 10 minutes is long enough for any realistic interactive auth ceremony
34
+ # without leaving CI hangs unbounded.
35
+ INTERACTIVE_AUTOMATION_TIMEOUT_SEC = 600
36
+
37
+
38
+ def run_automation(
39
+ name: str,
40
+ op: str,
41
+ code: str,
42
+ allow_stdin: bool = False,
43
+ path: str = None,
44
+ timeout: Optional[float] = None,
45
+ ):
46
+ from colab_cli.common import state
47
+
48
+ s = state.store.get(name)
49
+ runtime = ColabRuntime(s.url, s.token, session_name=s.name, history=state.history)
50
+
51
+ def drivefs_hook(deserialize_msg, wsclient):
52
+ content = deserialize_msg.get("content", {})
53
+ if content.get("request", {}).get("authType") == "dfs_ephemeral":
54
+ msg_id = deserialize_msg.get("metadata", {}).get("colab_msg_id")
55
+ state.history.log_event(
56
+ s.name,
57
+ "colab_request",
58
+ {"type": "dfs_ephemeral", "colab_msg_id": msg_id},
59
+ )
60
+ url = f"{state.client.colab_domain}/tun/m/credentials-propagation/{s.endpoint}"
61
+ params = {
62
+ "authuser": "0",
63
+ "authtype": "dfs_ephemeral",
64
+ "version": "2",
65
+ "dryrun": "true",
66
+ "propagate": "true",
67
+ "record": "false",
68
+ }
69
+ typer.echo(
70
+ f"\n[colab] Intercepted Drive Auth Request. Connecting to {state.client.colab_domain}..."
71
+ )
72
+
73
+ creds = get_credentials(
74
+ state.client_oauth_config, provider=state.auth_provider
75
+ )
76
+ resp = creds.request("GET", url, params=params)
77
+ token = (
78
+ json.loads(resp.text.split("\n", 1)[-1]).get("token")
79
+ if get_status_code(resp) == 200
80
+ else None
81
+ )
82
+
83
+ headers = {"x-goog-colab-token": token}
84
+ resp = creds.request(
85
+ "POST",
86
+ url,
87
+ params=params,
88
+ headers=headers,
89
+ files={"file_id": (None, "empty.ipynb")},
90
+ )
91
+ data = json.loads(resp.text.split("\n", 1)[-1])
92
+
93
+ if not data.get("success"):
94
+ uri = data.get("unauthorized_redirect_uri")
95
+ typer.echo(
96
+ f"\n[colab] REQUIRED: Google Drive Authorization needed.\nPlease visit:\n\n{uri}\n"
97
+ )
98
+ state.history.log_event(s.name, "drive_auth_needed", {"uri": uri})
99
+ sys.stdout.write("Press Enter after you have granted access... ")
100
+ sys.stdout.flush()
101
+ with open("/dev/tty") as tty:
102
+ tty.readline()
103
+
104
+ typer.echo("[colab] Authorizing VM...")
105
+ params["dryrun"] = "false"
106
+ resp = creds.request(
107
+ "POST",
108
+ url,
109
+ params=params,
110
+ headers=headers,
111
+ files={"file_id": (None, "empty.ipynb")},
112
+ )
113
+ if get_status_code(resp) == 200:
114
+ typer.echo("[colab] Credentials propagated. Resuming mount...")
115
+ state.history.log_event(s.name, "drive_auth_success", {})
116
+ reply = wsclient.session.msg(
117
+ "input_reply",
118
+ {"value": {"type": "colab_reply", "colab_msg_id": msg_id}},
119
+ )
120
+ if "header" in deserialize_msg:
121
+ reply["parent_header"] = deserialize_msg["header"]
122
+ wsclient.stdin_channel.send(reply)
123
+ else:
124
+ typer.echo(
125
+ f"[colab] Error propagating: {get_status_code(resp)} {resp.text}"
126
+ )
127
+ return True
128
+ return False
129
+
130
+ runtime.colab_request_hook = drivefs_hook
131
+ try:
132
+ s.running = f"automation({op})"
133
+ s.last_execution = (
134
+ f"automation:{op}",
135
+ None,
136
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
137
+ )
138
+ state.store.add(s)
139
+
140
+ if op == "drivemount":
141
+ state.history.log_event(
142
+ name, "automation", {"op": "drivemount", "path": path, "code": code}
143
+ )
144
+ else:
145
+ state.history.log_event(name, "automation", {"op": op, "code": code})
146
+
147
+ outputs = runtime.execute_code(code, allow_stdin=allow_stdin, timeout=timeout)
148
+ state.history.log_event(
149
+ name, "automation_result", {"op": op, "outputs": outputs}
150
+ )
151
+
152
+ for out in outputs:
153
+ if "text" in out:
154
+ sys.stdout.write(out["text"])
155
+ elif "data" in out:
156
+ if "text/plain" in out["data"]:
157
+ typer.echo(out["data"]["text/plain"])
158
+ elif out.get("output_type") == "error":
159
+ ename = out.get("ename", "Error")
160
+ evalue = out.get("evalue", "")
161
+ tb = out.get("traceback", [])
162
+ if tb:
163
+ sys.stderr.write("".join(tb) + "\n")
164
+ else:
165
+ sys.stderr.write(f"{ename}: {evalue}\n")
166
+ finally:
167
+ s.running = None
168
+ state.store.add(s)
169
+ runtime.stop()
170
+
171
+
172
+ def auth(
173
+ session: Annotated[
174
+ Optional[str], typer.Option("-s", "--session", help="Session name")
175
+ ] = None,
176
+ ):
177
+ """Authenticate with Google on the VM"""
178
+ from colab_cli.common import state
179
+
180
+ name = state.resolve_session(session)
181
+ code = "import os\nos.environ['USE_AUTH_EPHEM'] = '0'\nfrom google.colab import auth\nauth.authenticate_user()"
182
+ typer.echo(f"[colab] Starting Google Auth flow on {name}...")
183
+ run_automation(
184
+ name,
185
+ "auth",
186
+ code,
187
+ allow_stdin=True,
188
+ timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
189
+ )
190
+
191
+
192
+ def drivemount(
193
+ session: Annotated[
194
+ Optional[str], typer.Option("-s", "--session", help="Session name")
195
+ ] = None,
196
+ path: Annotated[str, typer.Argument(help="Mount path")] = "/content/drive",
197
+ ):
198
+ """Mount Google Drive at path"""
199
+ from colab_cli.common import state
200
+
201
+ name = state.resolve_session(session)
202
+ code = f"from google.colab import drive\ndrive.mount('{path}')"
203
+ typer.echo(f"[colab] Mounting Google Drive to '{path}' on {name}...")
204
+ run_automation(
205
+ name,
206
+ "drivemount",
207
+ code,
208
+ allow_stdin=True,
209
+ path=path,
210
+ timeout=INTERACTIVE_AUTOMATION_TIMEOUT_SEC,
211
+ )
212
+
213
+
214
+ def install(
215
+ session: Annotated[
216
+ Optional[str], typer.Option("-s", "--session", help="Session name")
217
+ ] = None,
218
+ packages: Annotated[
219
+ Optional[List[str]], typer.Argument(help="Packages to install")
220
+ ] = None,
221
+ requirement: Annotated[
222
+ Optional[str], typer.Option("-r", "--requirement", help="Requirements file")
223
+ ] = None,
224
+ ):
225
+ """Install python packages on the VM"""
226
+ from colab_cli.common import state
227
+
228
+ name = state.resolve_session(session)
229
+ if not packages and not requirement:
230
+ typer.echo("[colab] No packages or requirements specified.")
231
+ raise typer.Exit(1)
232
+
233
+ commands = []
234
+ if requirement:
235
+ if not os.path.isfile(requirement):
236
+ typer.echo(f"[colab] Requirements file '{requirement}' not found locally.")
237
+ raise typer.Exit(1)
238
+ contents = ContentsClient(state.store.get(name))
239
+ remote_path = f"content/{os.path.basename(requirement)}"
240
+ contents.upload(requirement, remote_path)
241
+ commands.extend(["-r", f"/{remote_path}"])
242
+ if packages:
243
+ commands.extend(packages)
244
+
245
+ cmd_str = ", ".join(f"'{c}'" for c in commands)
246
+ code = f"""
247
+ import subprocess, sys
248
+ def install():
249
+ packages = [{cmd_str}]
250
+ try:
251
+ subprocess.check_call(['uv', 'pip', 'install', '--system'] + packages)
252
+ print('Installation Complete (via uv)!')
253
+ except:
254
+ subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + packages)
255
+ print('Installation Complete (via pip)!')
256
+ install()
257
+ """
258
+ typer.echo(f"[colab] Installing packages on {name} (preferring uv)...")
259
+ run_automation(name, "install", code)
260
+
261
+
262
+ def register(app: typer.Typer):
263
+ app.command(hidden=True)(auth)
264
+ app.command()(drivemount)
265
+ app.command()(install)