ttsd-colabcli 1.0.0
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.
- package/cli.js +148 -0
- package/core/app/__init__.py +0 -0
- package/core/app/colab_cli/__init__.py +0 -0
- package/core/app/colab_cli/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auth.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/auto_update.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/cli.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/client.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/common.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/console.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/contents.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/history.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/runtime.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/state.cpython-312.pyc +0 -0
- package/core/app/colab_cli/__pycache__/utils.cpython-312.pyc +0 -0
- package/core/app/colab_cli/auth.py +278 -0
- package/core/app/colab_cli/auto_update.py +248 -0
- package/core/app/colab_cli/cli.py +155 -0
- package/core/app/colab_cli/client.py +310 -0
- package/core/app/colab_cli/commands/__init__.py +14 -0
- package/core/app/colab_cli/commands/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/automation.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/execution.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/files.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/run.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/session.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/__pycache__/utility.cpython-312.pyc +0 -0
- package/core/app/colab_cli/commands/automation.py +265 -0
- package/core/app/colab_cli/commands/execution.py +362 -0
- package/core/app/colab_cli/commands/files.py +204 -0
- package/core/app/colab_cli/commands/run.py +477 -0
- package/core/app/colab_cli/commands/session.py +519 -0
- package/core/app/colab_cli/commands/utility.py +436 -0
- package/core/app/colab_cli/common.py +185 -0
- package/core/app/colab_cli/console.py +172 -0
- package/core/app/colab_cli/contents.py +93 -0
- package/core/app/colab_cli/converter.py +184 -0
- package/core/app/colab_cli/history.py +65 -0
- package/core/app/colab_cli/oauth_config.json +11 -0
- package/core/app/colab_cli/repl.py +173 -0
- package/core/app/colab_cli/runtime.py +262 -0
- package/core/app/colab_cli/state.py +156 -0
- package/core/app/colab_cli/utils.py +85 -0
- package/core/colab/worker.py +679 -0
- package/core/daemon.py +184 -0
- package/core/requirements.txt +8 -0
- package/package.json +22 -0
|
@@ -0,0 +1,310 @@
|
|
|
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 app.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
|
+
"x-user-agent": "grpc-web-javascript/0.1",
|
|
307
|
+
"x-goog-api-client": "grpc-web/0.1",
|
|
308
|
+
}
|
|
309
|
+
# KeepAliveAssignmentRequest is a list containing the endpoint string
|
|
310
|
+
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
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 app.colab_cli.runtime import ColabRuntime
|
|
24
|
+
from app.colab_cli.contents import ContentsClient
|
|
25
|
+
from app.colab_cli.auth import get_credentials
|
|
26
|
+
from app.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 app.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 app.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 app.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 app.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)
|