feat: add --high-mem for high-RAM machine shape selection (#105)

Koji committed Aug 11, 2026 at 19:22 UTC b04e83a92aae3549f7bc734546d36d56a3146773
13 files changed +371 -73
README.md
+5 -4
@@ -68,9 +68,9 @@ Run `colab <command> --help` to view specific options, defaults, and detailed he
68 ### Session Management
69 | Command | Description |
70 | --- | --- |
71 -| `colab new [-s NAME] [--gpu GPU] [--tpu TPU]` | Allocate a new CPU, GPU, or TPU VM runtime |
71 +| `colab new [-s NAME] [--gpu GPU] [--tpu TPU] [--high-mem]` | Allocate a new CPU, GPU, or TPU VM runtime (optionally high-RAM) |
72 | `colab sessions` | List all active sessions currently active on the backend |
73 -| `colab status [-s NAME]` | Display hardware, status, and local metadata for active sessions |
73 +| `colab status [-s NAME]` | Display hardware, machine shape, status, and local metadata for active sessions |
74 | `colab restart-kernel [-s NAME]` | Restart the active session's Jupyter kernel |
75 | `colab stop [-s NAME]` | Terminate a session VM and tear down its keep-alive daemon |
76 | `colab url [-s NAME] [--open]` | Print or open a browser URL connecting to the active session |
@@ -78,11 +78,11 @@ Run `colab <command> --help` to view specific options, defaults, and detailed he
78 ### Execution
79 | Command | Description |
80 | --- | --- |
81 -| `colab run [--gpu GPU] [--tpu TPU] [--keep] SCRIPT [ARGS...]` | Run a local script on a fresh VM, forwarding arguments, then release it |
81 +| `colab run [--gpu GPU] [--tpu TPU] [--high-mem] [--keep] SCRIPT [ARGS...]` | Run a local script on a fresh VM, forwarding arguments, then release it |
82 | `colab exec [-s NAME] [-f FILE] [--output-image PATH]` | Execute Python code from stdin, a local `.py` file, or a `.ipynb` notebook |
83 | `colab repl [-s NAME] [--output-image PATH]` | Start an interactive Python REPL on the VM (exits cleanly on piped EOF) |
84 | `colab console [-s NAME]` | Connect to a raw interactive TTY shell (tmux) on the remote VM |
85 -| `colab ssh [-s NAME] [--proxy-mode] [-i KEY]` | Open an SSH shell to the runtime over WebSocket, or act as an OpenSSH `ProxyCommand` bridge for IDE remote-dev |
85 +| `colab ssh [-s NAME] [--proxy-mode] [-i KEY] [--gpu GPU] [--tpu TPU] [--high-mem]` | Open an SSH shell to the runtime over WebSocket, or act as an OpenSSH `ProxyCommand` bridge for IDE remote-dev |
86
87 ### File Operations
88 | Command | Description |
@@ -142,6 +142,7 @@ colab stop -s analysis
142
143 ## Usage Notes
144
145 +* **Machine shape:** Use `--high-mem` with `colab new`, `colab run`, or `colab ssh` (when auto-creating a runtime) to request a high-RAM machine shape. Requires Colab Pro or Pro+ entitlement for supported accelerators (CPU, T4, A100, etc.). L4 and TPU runtimes ignore this flag because they only offer one shape. Machine shape is shown in `colab sessions` and `colab status`.
146 * **TTY Requirements:** The interactive commands `repl` and `console` require a local TTY. When running inside automated scripts or pipelines, make sure to pipe stdin (e.g., `echo "print(1)" | colab repl`) to trigger non-interactive execution modes.
147 * **Transparent Code Execution:** When calling `colab exec -f file.py`, the CLI reads the file locally and transmits its content to the remote kernel. You do not need to manually upload files before execution.
148 * **Storage & State Paths:** Session tokens and metadata are stored at `~/.config/colab-cli/sessions.json`. Global CLI settings are located at `~/.config/colab-cli/settings.json`. These can be customized or isolated via the global `--config` flag.
docs/01_session_management.md
+13 -3
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +2026-08-09: Added `--high-mem` to `colab new`, `colab run`, and `colab ssh` (auto-create). Assign requests now send `shape=hm` when high-RAM is requested; `colab sessions` and `colab status` display machine shape.
4 2026-06-15: Switched the keep-alive daemon from the `colab.pa.googleapis.com` `RuntimeService/KeepAliveAssignment` RPC to a Tunnel Frontend HTTP ping (`GET /tun/m/<endpoint>/keep-alive/` with `X-Colab-Tunnel: Google`) on `colab.research.google.com`. The RPC required `serviceusage` consumer access to Colab's internal project `1014160490159`, which ordinary user accounts lack, so every external user hit HTTP 403 `USER_PROJECT_DENIED` and their CLI sessions were idle-pruned within minutes (issue #14). Reproduced live with a third-party account; verified the tunnel ping is accepted by the same bearer-token credential that already works for `assign`. A `ReadTimeout` on the ping is treated as success (TFE records activity before forwarding to the often-non-responding VM). Generalized the pre-flight remediation messaging away from the now-irrelevant `colaboratory`/`pa.googleapis.com` framing, and removed the dead grpc-web client-registry/API-key code.
5 2026-06-10: Replaced the POSIX-only `fcntl.flock` file locking in `_LockedFileStore` with the cross-platform `filelock` library (reported broken on Windows). Reads use `ReadWriteLock.read_lock()` (shared) and writes use `write_lock()` (exclusive), preserving the original `LOCK_SH`/`LOCK_EX` semantics. The lock is constructed with `is_singleton=False` so two `StateStore` instances for the same path in one process don't collapse into a single reentrant lock (which would raise `RuntimeError` on multi-threaded write contention). Added shared-read, cross-process exclusion, and multi-thread/multi-process regression tests.
6 ---
@@ -32,11 +33,20 @@ Defines the specific hardware model.
33 - `V5E1`: TPU v5e (1 core, optimized for inference/efficient training).
34 - `V6E1`: TPU v6e (1 core, high performance).
35
35 -### 3. CLI Mapping
36 +### 3. Machine shape (`shape`)
37 +Defines the RAM profile for runtimes that support a choice (CPU, T4, A100, etc.).
38 +- `STANDARD` (default): omit the `shape` query param on assign.
39 +- `HIGH_RAM`: send `shape=hm` on assign (requires Colab Pro/Pro+ entitlement).
40 +
41 +Accelerators with only one shape (L4, v5e1, v6e1) ignore `--high-mem`.
42 +
43 +### 4. CLI Mapping
44 The CLI maps user flags to these backend parameters:
45 - `colab new my-session` -> `variant=DEFAULT`, `accelerator=NONE`
38 -- `colab new my-session -gpu=L4` -> `variant=GPU`, `accelerator=L4`
39 -- `colab new my-session -tpu=v5e1` -> `variant=TPU`, `accelerator=V5E1`
46 +- `colab new my-session --gpu=L4` -> `variant=GPU`, `accelerator=L4`
47 +- `colab new my-session --tpu=v5e1` -> `variant=TPU`, `accelerator=V5E1`
48 +- `colab new my-session --high-mem` -> adds `shape=hm` (when supported)
49 +- `colab new my-session --gpu A100 --high-mem` -> `variant=GPU`, `accelerator=A100`, `shape=hm`
50
51 ## Approach
52
docs/05_run_command.md
+2
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +2026-08-09: Added `--high-mem` flag (passthrough to session creation; sends `shape=hm` on assign when supported).
4 2026-05-12: Initial design and implementation of `colab run <script.py> [args...]`. Combines `colab new` + `colab exec` + `colab stop` into a single fire-and-forget invocation so a Python file can use `#!/usr/bin/env -S colab run` as a shebang line and execute on a freshly-allocated Colab VM. Adds `--keep` (skip auto-stop), `--gpu` / `--tpu` (passthrough to session creation), `-s/--session` (name the ephemeral session), and propagates the script's exit status (non-zero on any uncaught exception in the kernel). The script's `sys.argv` is re-set inside the kernel to mirror native `python script.py arg1 arg2` semantics, and `__name__` is set to `"__main__"`.
5 2026-05-12: Native CPython exit-code semantics for `sys.exit()` / `raise SystemExit(...)` from the script body. The Colab kernel reports a `SystemExit` as `output_type=='error'`, which under the previous logic would have (a) printed the IPython traceback (`An exception has occurred, use %tb...`) and (b) flagged the run as a failure regardless of the integer exit code. Now: `sys.exit()` / `sys.exit(0)` exit 0 silently; `sys.exit(N)` exits N; `sys.exit('msg')` exits 1 (matching CPython). The IPython "To exit: use 'exit', 'quit', or Ctrl-D." UserWarning is filtered via the prelude. Encoded after running `examples/gpu_hello.py` end-to-end and seeing the noisy `SystemExit: 0` traceback at the end of an otherwise-successful GPU run.
6 2026-06-04: Bumped the default value of the `--timeout` flag from 10.0s to 30.0s so short-but-silent tasks aren't prematurely killed out of the box. Mirrors the same change for `colab exec`.
@@ -29,6 +30,7 @@ colab run [OPTIONS] SCRIPT [SCRIPT_ARGS]...
30 | `-s`, `--session` | str | auto | Name the ephemeral session (helpful with `--keep`). Auto-generated as `run-<6 hex>` if omitted. |
31 | `--gpu` | str | None | Same set as `colab new --gpu` (T4, L4, G4, H100, A100). |
32 | `--tpu` | str | None | Same set as `colab new --tpu` (v5e1, v6e1). |
33 +| `--high-mem` | bool | False | Same as `colab new --high-mem` — request high-RAM when supported. |
34 | `--keep` | bool | False | Do **not** stop the session after the script finishes. |
35 | `--timeout` | float | 30.0 | Timeout in seconds for code execution to prevent hanging on silent tasks. |
36
docs/06_ssh_access.md
+2
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +2026-08-09: Added `--high-mem` passthrough when `colab ssh` auto-creates a runtime (forwards to `colab new --high-mem`).
4 2026-07-17: Initial design and implementation of `colab ssh` — client side of SSH-over-WebSocket runtime access. Adds three modes (interactive shell, `-s SESSION`, and `--proxy-mode` OpenSSH ProxyCommand bridge), `--identity/-i` key selection, and per-HTTP-status handshake error messages. Server side is out of scope for this repo; the subcommand is a no-op against runtimes that do not expose the `/colab/ssh` endpoint (surfaces an actionable HTTP 404 message).
5 2026-07-22: Bare `colab ssh` now auto-creates a runtime (via `colab new`) when you have no active session, with `--gpu/--tpu` passthrough and `--rm` to stop an auto-created runtime on exit. Fixed two client bugs: the dead 403 branch (feature-off returns 404, not 403) and the RSA guidance (all `ssh-rsa` keys are server-rejected, so `id_rsa` is no longer auto-scanned and the 400 message no longer advertises `rsa-sha2`). Added `tests/test_ssh_wire_contract.py` (real loopback-server wire assertions) and `tests/test_ssh_autocreate.py`.
6 2026-07-22: Interactive `colab ssh` now starts in `/content` (Colab's working dir) instead of `/root`, via a forced PTY (`-t`) plus a remote `cd /content 2>/dev/null; exec $SHELL -l`. A missing `/content` falls back to the login home. Added `tests/test_ssh_workdir.py`.
@@ -30,6 +31,7 @@ colab ssh [OPTIONS]
31 | `-i`, `--identity` | str | auto | Private key for the public key sent to Colab. Default: first of `~/.ssh/id_ed25519`, `id_ecdsa`. |
32 | `--gpu` | str | None | GPU accelerator for a runtime this command creates (T4, L4, G4, H100, A100). |
33 | `--tpu` | str | None | TPU accelerator for a runtime this command creates (v5e1, v6e1). |
34 +| `--high-mem` | bool | False | Request high-RAM when this command auto-creates a runtime (ignored when connecting to an existing session). |
35 | `--rm` | bool | False | Stop the runtime when the session ends. Interactive: only a runtime `colab ssh` auto-created (a reused session is never removed). `--proxy-mode`: the bridged session, on disconnect. |
36
37 ### `~/.ssh/config` usage
src/colab_cli/client.py
+43 -4
@@ -91,6 +91,37 @@ class Shape(int, Enum):
91 HIGH_RAM = 1
92
93
94 +# Accelerators that only exist in a single (high-memory) shape; the assign
95 +# endpoint ignores shape=hm for these (colab-vscode forces STANDARD).
96 +HIGH_MEM_ONLY_ACCELERATORS = frozenset(
97 + {Accelerator.L4, Accelerator.V5E1, Accelerator.V6E1}
98 +)
99 +
100 +
101 +def resolve_assign_shape(
102 + accelerator: Optional[Accelerator],
103 + *,
104 + high_mem: bool = False,
105 +) -> Optional[Shape]:
106 + """Map CLI intent to the shape query param for /tun/m/assign.
107 +
108 + Returns ``Shape.HIGH_RAM`` when high memory was requested and the
109 + accelerator supports a choice; otherwise ``None`` (omit the URL param).
110 + """
111 + if not high_mem:
112 + return None
113 + if accelerator in HIGH_MEM_ONLY_ACCELERATORS:
114 + return None
115 + return Shape.HIGH_RAM
116 +
117 +
118 +def shape_display_label(shape: Union[Shape, str, int, None]) -> str:
119 + """Human-friendly label for sessions/status output."""
120 + if shape in (Shape.HIGH_RAM, "HIGH_RAM", 1):
121 + return "High-RAM"
122 + return "Standard"
123 +
124 +
125 class RuntimeProxyInfo(BaseModel):
126 token: str
127 token_expires_in_seconds: int = Field(..., alias="tokenExpiresInSeconds")
@@ -228,14 +259,17 @@ class Client:
259 notebook_hash: uuid.UUID,
260 variant: Optional[Variant] = None,
261 accelerator: Optional[Accelerator] = None,
262 + shape: Optional[Shape] = None,
263 ) -> Union[PostAssignmentResponse, Assignment]:
232 - assignment = self._get_assignment(notebook_hash, variant, accelerator)
264 + assignment = self._get_assignment(
265 + notebook_hash, variant, accelerator, shape
266 + )
267 if isinstance(assignment, Assignment):
268 return assignment
269
270 try:
271 res = self._post_assignment(
238 - notebook_hash, assignment.token, variant, accelerator
272 + notebook_hash, assignment.token, variant, accelerator, shape
273 )
274 except ColabRequestError as e:
275 if get_status_code(e) == 412:
@@ -249,6 +283,7 @@ class Client:
283 notebook_hash: uuid.UUID,
284 variant: Optional[Variant] = None,
285 accelerator: Optional[Accelerator] = None,
286 + shape: Optional[Shape] = None,
287 ) -> str:
288 url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assign")
289 params = {"nbh": uuid_to_web_safe_base64(notebook_hash)}
@@ -256,6 +291,8 @@ class Client:
291 params["variant"] = variant.value
292 if accelerator:
293 params["accelerator"] = accelerator.value
294 + if shape == Shape.HIGH_RAM:
295 + params["shape"] = "hm"
296
297 req = requests.Request("GET", url, params=params)
298 prep = req.prepare()
@@ -266,8 +303,9 @@ class Client:
303 notebook_hash: uuid.UUID,
304 variant: Optional[Variant] = None,
305 accelerator: Optional[Accelerator] = None,
306 + shape: Optional[Shape] = None,
307 ) -> Union[GetAssignmentResponse, Assignment]:
270 - url = self._build_assign_url(notebook_hash, variant, accelerator)
308 + url = self._build_assign_url(notebook_hash, variant, accelerator, shape)
309 return self._issue_request(url, schema=Union[GetAssignmentResponse, Assignment])
310
311 def _post_assignment(
@@ -276,8 +314,9 @@ class Client:
314 xsrf_token: str,
315 variant: Optional[Variant] = None,
316 accelerator: Optional[Accelerator] = None,
317 + shape: Optional[Shape] = None,
318 ) -> PostAssignmentResponse:
280 - url = self._build_assign_url(notebook_hash, variant, accelerator)
319 + url = self._build_assign_url(notebook_hash, variant, accelerator, shape)
320 headers = {COLAB_XSRF_TOKEN_HEADER["key"]: xsrf_token}
321 return self._issue_request(
322 url, method="POST", headers=headers, schema=PostAssignmentResponse
src/colab_cli/commands/run.py
+28 -24
@@ -41,13 +41,15 @@ from typing_extensions import Annotated
41 from colab_cli.client import (
42 Accelerator,
43 ColabRequestError,
44 + HIGH_MEM_ONLY_ACCELERATORS,
45 PostAssignmentResponse,
45 - Variant,
46 + Shape,
47 )
48 from colab_cli.commands.execution import _build_env_prelude, _parse_env_vars
49 from colab_cli.commands.session import (
50 _is_scope_error,
51 _scope_remediation_message,
52 + resolve_runtime_options,
53 spawn_keep_alive,
54 )
55 from colab_cli.runtime import ColabRuntime
@@ -55,27 +57,6 @@ from colab_cli.state import SessionState
57 from colab_cli.utils import get_status_code, is_terminal_error
58
59
58 -# TODO(sethtroisi): dedupe this logic with similar in session.py
59 -def _resolve_accelerator(gpu: Optional[str], tpu: Optional[str]):
60 - """Mirror the mapping logic in `commands.session.new`. Centralised so the
61 - two commands stay in lock-step on supported accelerator names.
62 - """
63 - if tpu:
64 - variant = Variant.TPU
65 - accelerator = Accelerator.V5E1 if tpu.lower() == "v5e1" else Accelerator.V6E1
66 - return variant, accelerator
67 - if gpu:
68 - mapping = {
69 - "a100": Accelerator.A100,
70 - "h100": Accelerator.H100,
71 - "l4": Accelerator.L4,
72 - "t4": Accelerator.T4,
73 - "g4": Accelerator.G4,
74 - }
75 - return Variant.GPU, mapping.get(gpu.lower(), Accelerator.A100)
76 - return Variant.DEFAULT, Accelerator.NONE
77 -
78 -
60 def _build_script_payload(
61 script_path: str, script_args: List[str], env_vars: Optional[dict[str, str]] = None
62 ) -> str:
@@ -259,6 +240,16 @@ def run_command(
240 ),
241 ),
242 ] = None,
243 + high_mem: Annotated[
244 + bool,
245 + typer.Option(
246 + "--high-mem",
247 + help=(
248 + "Request a high-RAM machine shape. Requires Colab Pro or Pro+ "
249 + "entitlement. Ignored for L4 and TPU accelerators."
250 + ),
251 + ),
252 + ] = False,
253 keep: Annotated[
254 bool,
255 typer.Option(
@@ -306,12 +297,21 @@ def run_command(
297 raise typer.Exit(2)
298
299 name = session or f"run-{uuid.uuid4().hex[:6]}"
309 - variant, accelerator = _resolve_accelerator(gpu, tpu)
300 + variant, accelerator, shape = resolve_runtime_options(
301 + gpu, tpu, high_mem=high_mem
302 + )
303 +
304 + if high_mem and accelerator in HIGH_MEM_ONLY_ACCELERATORS:
305 + typer.echo(
306 + "[colab] --high-mem ignored: this accelerator only offers one "
307 + "machine shape.",
308 + err=True,
309 + )
310
311 typer.echo(f"[colab] Creating session '{name}'...", err=True)
312 try:
313 res = state.client.assign(
314 - uuid.uuid4(), variant=variant, accelerator=accelerator
314 + uuid.uuid4(), variant=variant, accelerator=accelerator, shape=shape
315 )
316 except ColabRequestError as e:
317 # Mirror `colab new`'s friendly accelerator-quota message.
@@ -346,6 +346,9 @@ def run_command(
346 endpoint=endpoint,
347 variant=variant.value,
348 accelerator=accelerator.value,
349 + machine_shape=(
350 + Shape.HIGH_RAM.name if shape == Shape.HIGH_RAM else Shape.STANDARD.name
351 + ),
352 )
353
354 # Pre-flight keep-alive: same scope-detection dance as `colab new` so a
@@ -384,6 +387,7 @@ def run_command(
387 "endpoint": endpoint,
388 "variant": variant.value,
389 "accelerator": accelerator.value,
390 + "machine_shape": s.machine_shape,
391 "via": "run",
392 },
393 )
src/colab_cli/commands/session.py
+60 -17
@@ -24,8 +24,12 @@ from typing_extensions import Annotated
24 from colab_cli.client import (
25 Accelerator,
26 ColabRequestError,
27 + HIGH_MEM_ONLY_ACCELERATORS,
28 PostAssignmentResponse,
29 + Shape,
30 Variant,
31 + resolve_assign_shape,
32 + shape_display_label,
33 )
34 from colab_cli.utils import get_status_code
35 from colab_cli.state import SessionState
@@ -95,16 +99,18 @@ def _format_session_line(
99 accelerator: str,
100 variant: str,
101 status: Optional[str] = None,
102 + machine_shape: Optional[str] = None,
103 ) -> str:
104 """Single source of truth for session display lines.
105
101 - Format: ``[name] endpoint | Hardware: X | Variant: Y[ | Status: Z]``.
106 + Format: ``[name] endpoint | Hardware: X | Shape: Y | Variant: Z[ | Status: W]``.
107 Use ``"?"`` as the name for orphaned server-side assignments with no local
108 state.
109 """
110 parts = [
111 f"[{name}] {endpoint}",
112 f"Hardware: {_hardware_label(accelerator)}",
113 + f"Shape: {shape_display_label(machine_shape)}",
114 f"Variant: {variant}",
115 ]
116 if status is not None:
@@ -112,6 +118,34 @@ def _format_session_line(
118 return " | ".join(parts)
119
120
121 +def resolve_runtime_options(
122 + gpu: Optional[str] = None,
123 + tpu: Optional[str] = None,
124 + *,
125 + high_mem: bool = False,
126 +) -> tuple[Variant, Accelerator, Optional[Shape]]:
127 + """Map CLI flags to backend variant, accelerator, and optional shape."""
128 + if tpu:
129 + variant = Variant.TPU
130 + accelerator = Accelerator.V5E1 if tpu.lower() == "v5e1" else Accelerator.V6E1
131 + elif gpu:
132 + variant = Variant.GPU
133 + mapping = {
134 + "a100": Accelerator.A100,
135 + "h100": Accelerator.H100,
136 + "l4": Accelerator.L4,
137 + "t4": Accelerator.T4,
138 + "g4": Accelerator.G4,
139 + }
140 + accelerator = mapping.get(gpu.lower(), Accelerator.A100)
141 + else:
142 + variant = Variant.DEFAULT
143 + accelerator = Accelerator.NONE
144 +
145 + shape = resolve_assign_shape(accelerator, high_mem=high_mem)
146 + return variant, accelerator, shape
147 +
148 +
149 def new(
150 session: Annotated[
151 Optional[str], typer.Option("-s", "--session", help="Session name")
@@ -132,32 +166,35 @@ def new(
166 ),
167 ),
168 ] = None,
169 + high_mem: Annotated[
170 + bool,
171 + typer.Option(
172 + "--high-mem",
173 + help=(
174 + "Request a high-RAM machine shape (CPU, T4, A100, etc.). "
175 + "Requires Colab Pro or Pro+ entitlement. Ignored for "
176 + "accelerators that only offer a single shape (L4, v5e1, v6e1)."
177 + ),
178 + ),
179 + ] = False,
180 ):
181 """Create a new session"""
182 from colab_cli.common import state
183
184 name = session or uuid.uuid4().hex[:6]
140 - variant = Variant.DEFAULT
141 - accelerator = Accelerator.NONE
185 + variant, accelerator, shape = resolve_runtime_options(gpu, tpu, high_mem=high_mem)
186
143 - if tpu:
144 - variant = Variant.TPU
145 - accelerator = Accelerator.V5E1 if tpu.lower() == "v5e1" else Accelerator.V6E1
146 - elif gpu:
147 - variant = Variant.GPU
148 - mapping = {
149 - "a100": Accelerator.A100,
150 - "h100": Accelerator.H100,
151 - "l4": Accelerator.L4,
152 - "t4": Accelerator.T4,
153 - "g4": Accelerator.G4,
154 - }
155 - accelerator = mapping.get(gpu.lower(), Accelerator.A100)
187 + if high_mem and accelerator in HIGH_MEM_ONLY_ACCELERATORS:
188 + typer.echo(
189 + "[colab] --high-mem ignored: this accelerator only offers one "
190 + "machine shape.",
191 + err=True,
192 + )
193
194 typer.echo(f"[colab] Creating session '{name}'...")
195 try:
196 res = state.client.assign(
160 - uuid.uuid4(), variant=variant, accelerator=accelerator
197 + uuid.uuid4(), variant=variant, accelerator=accelerator, shape=shape
198 )
199 except ColabRequestError as e:
200 # The Colab backend returns 400 when the caller is not entitled to the
@@ -198,6 +235,9 @@ def new(
235 endpoint=endpoint,
236 variant=variant.value,
237 accelerator=accelerator.value,
238 + machine_shape=(
239 + Shape.HIGH_RAM.name if shape == Shape.HIGH_RAM else Shape.STANDARD.name
240 + ),
241 )
242
243 # Pre-flight the keep-alive ping once. If it returns a 403 caused by
@@ -244,6 +284,7 @@ def new(
284 "endpoint": endpoint,
285 "variant": variant.value,
286 "accelerator": accelerator.value,
287 + "machine_shape": s.machine_shape,
288 },
289 )
290 typer.echo("[colab] Session READY.")
@@ -305,6 +346,7 @@ def sessions_command():
346 endpoint=a.endpoint,
347 accelerator=a.accelerator.value,
348 variant=a.variant.name,
349 + machine_shape=a.machine_shape.name,
350 )
351 )
352
@@ -319,6 +361,7 @@ def _print_status_for(s: SessionState) -> None:
361 accelerator=s.accelerator,
362 variant=s.variant,
363 status=status,
364 + machine_shape=s.machine_shape,
365 )
366 )
367 if s.last_execution:
src/colab_cli/commands/ssh.py
+43 -14
@@ -177,7 +177,11 @@ def _has_local_sessions() -> bool:
177
178
179 def _auto_create_session(
180 - gpu: Optional[str], tpu: Optional[str], name: Optional[str] = None
180 + gpu: Optional[str],
181 + tpu: Optional[str],
182 + name: Optional[str] = None,
183 + *,
184 + high_mem: bool = False,
185 ) -> SessionState:
186 """Creates a runtime via ``colab new`` and returns its session.
187
@@ -196,7 +200,7 @@ def _auto_create_session(
200
201 name = name or uuid.uuid4().hex[:6]
202 typer.echo(f"[colab] Creating runtime '{name}'...")
199 - session_cmd.new(session=name, gpu=gpu, tpu=tpu)
203 + session_cmd.new(session=name, gpu=gpu, tpu=tpu, high_mem=high_mem)
204 return _resolve_session(name)
205
206
@@ -456,7 +460,11 @@ def _run_interactive_ssh(session: SessionState, identity: Optional[str]) -> int:
460
461
462 def _select_proxy_session(
459 - session: Optional[str], gpu: Optional[str], tpu: Optional[str]
463 + session: Optional[str],
464 + gpu: Optional[str],
465 + tpu: Optional[str],
466 + *,
467 + high_mem: bool = False,
468 ) -> tuple[SessionState, bool]:
469 """Resolves (or creates) the session for ``--proxy-mode``.
470
@@ -475,12 +483,18 @@ def _select_proxy_session(
483 """
484 if session and not _session_exists(session):
485 with contextlib.redirect_stdout(sys.stderr):
478 - return _auto_create_session(gpu, tpu, name=session), True
486 + return _auto_create_session(
487 + gpu, tpu, name=session, high_mem=high_mem
488 + ), True
489 return _resolve_session(session), False
490
491
492 def _select_interactive_session(
483 - session: Optional[str], gpu: Optional[str], tpu: Optional[str]
493 + session: Optional[str],
494 + gpu: Optional[str],
495 + tpu: Optional[str],
496 + *,
497 + high_mem: bool = False,
498 ) -> tuple[SessionState, bool]:
499 """Resolves (or auto-creates) the session for an interactive shell.
500
@@ -496,17 +510,22 @@ def _select_interactive_session(
510 A ``(session_state, created)`` pair.
511 """
512 if not session and not _has_local_sessions():
499 - return _auto_create_session(gpu, tpu), True
513 + return _auto_create_session(gpu, tpu, high_mem=high_mem), True
514 return _resolve_session(session), False
515
516
517 def _warn_accelerator_ignored(
504 - gpu: Optional[str], tpu: Optional[str], created: bool
518 + gpu: Optional[str],
519 + tpu: Optional[str],
520 + created: bool,
521 + *,
522 + high_mem: bool = False,
523 ) -> None:
506 - """Warns that ``--gpu/--tpu`` are no-ops when no runtime was created."""
507 - if (gpu or tpu) and not created:
524 + """Warns that ``--gpu/--tpu/--high-mem`` are no-ops when no runtime was created."""
525 + if (gpu or tpu or high_mem) and not created:
526 typer.echo(
509 - "[colab] --gpu/--tpu ignored: only applies to a created runtime.",
527 + "[colab] --gpu/--tpu/--high-mem ignored: only applies to a "
528 + "created runtime.",
529 err=True,
530 )
531
@@ -647,6 +666,16 @@ def ssh(
666 ),
667 ),
668 ] = None,
669 + high_mem: Annotated[
670 + bool,
671 + typer.Option(
672 + "--high-mem",
673 + help=(
674 + "Request a high-RAM machine shape when this command "
675 + "auto-creates a runtime."
676 + ),
677 + ),
678 + ] = False,
679 rm: Annotated[
680 bool,
681 typer.Option(
@@ -669,12 +698,12 @@ def ssh(
698 ``--gpu/--tpu`` set its accelerator, ``--rm`` stops it on disconnect).
699 """
700 if proxy_mode:
672 - s, created = _select_proxy_session(session, gpu, tpu)
673 - _warn_accelerator_ignored(gpu, tpu, created)
701 + s, created = _select_proxy_session(session, gpu, tpu, high_mem=high_mem)
702 + _warn_accelerator_ignored(gpu, tpu, created, high_mem=high_mem)
703 raise typer.Exit(code=_run_proxy_bridge(s, identity, rm))
704
676 - s, created = _select_interactive_session(session, gpu, tpu)
677 - _warn_accelerator_ignored(gpu, tpu, created)
705 + s, created = _select_interactive_session(session, gpu, tpu, high_mem=high_mem)
706 + _warn_accelerator_ignored(gpu, tpu, created, high_mem=high_mem)
707 raise typer.Exit(code=_run_interactive_shell(s, identity, created, rm))
708
709
src/colab_cli/state.py
+1
@@ -29,6 +29,7 @@ class SessionState(BaseModel):
29 endpoint: str
30 variant: str = "DEFAULT"
31 accelerator: str = "NONE"
32 + machine_shape: str = "STANDARD"
33 kernel_id: Optional[str] = None
34 session_id: Optional[str] = None
35 last_execution: Optional[Tuple[str, Optional[str], str]] = None
tests/test_cli.py
+40 -6
@@ -104,12 +104,13 @@ def test_cli_new_gpu_variants(mock_client, mock_store, gpu_flag, expected_acc):
104
105 def test_cli_sessions_unified_format(mock_client, mock_common_state):
106 """`sessions` should lead each line with the local name when known:
107 - `[name] endpoint | Hardware: X | Variant: Y`.
107 + `[name] endpoint | Hardware: X | Shape: Y | Variant: Z`.
108 """
109 mock_assignment = MagicMock()
110 mock_assignment.endpoint = "e1"
111 mock_assignment.variant.name = "GPU"
112 mock_assignment.accelerator.value = "T4"
113 + mock_assignment.machine_shape.name = "STANDARD"
114
115 mock_session_state = MagicMock()
116 mock_session_state.name = "s1"
@@ -123,7 +124,7 @@ def test_cli_sessions_unified_format(mock_client, mock_common_state):
124
125 result = runner.invoke(app, ["sessions"])
126 assert result.exit_code == 0
126 - assert "[s1] e1 | Hardware: T4 | Variant: GPU" in result.output
127 + assert "[s1] e1 | Hardware: T4 | Shape: Standard | Variant: GPU" in result.output
128
129
130 def test_cli_sessions_orphaned_assignment_marked(mock_client, mock_common_state):
@@ -132,13 +133,17 @@ def test_cli_sessions_orphaned_assignment_marked(mock_client, mock_common_state)
133 mock_assignment.endpoint = "orphan-ep"
134 mock_assignment.variant.name = "DEFAULT"
135 mock_assignment.accelerator.value = "NONE"
136 + mock_assignment.machine_shape.name = "HIGH_RAM"
137
138 mock_common_state.sync_sessions.return_value = ({}, [mock_assignment])
139
140 result = runner.invoke(app, ["sessions"])
141 assert result.exit_code == 0
142 # CPU is the alias for accelerator NONE
141 - assert "[?] orphan-ep | Hardware: CPU | Variant: DEFAULT" in result.output
143 + assert (
144 + "[?] orphan-ep | Hardware: CPU | Shape: High-RAM | Variant: DEFAULT"
145 + in result.output
146 + )
147
148
149 def test_cli_sessions_no_assignments(mock_client, mock_common_state):
@@ -154,6 +159,7 @@ def test_cli_status(mock_store, mock_common_state):
159 mock_session_state.endpoint = "e1"
160 mock_session_state.accelerator = "NONE"
161 mock_session_state.variant = "DEFAULT"
162 + mock_session_state.machine_shape = "STANDARD"
163 mock_session_state.running = None
164 mock_session_state.last_execution = (
165 "my_notebook.ipynb",
@@ -167,7 +173,10 @@ def test_cli_status(mock_store, mock_common_state):
173 # Test with explicit session: uses unified format including endpoint and Status
174 result = runner.invoke(app, ["status", "-s", "s1"])
175 assert result.exit_code == 0
170 - assert "[s1] e1 | Hardware: CPU | Variant: DEFAULT | Status: IDLE" in result.output
176 + assert (
177 + "[s1] e1 | Hardware: CPU | Shape: Standard | Variant: DEFAULT | Status: IDLE"
178 + in result.output
179 + )
180 assert (
181 "Last Execution: my_notebook.ipynb | Cell: cell_1 at 2023-10-27 12:00:00"
182 in result.output
@@ -184,7 +193,10 @@ def test_cli_status(mock_store, mock_common_state):
193 mock_store.get.return_value = mock_session_state
194 result = runner.invoke(app, ["status"])
195 assert result.exit_code == 0
187 - assert "[s1] e1 | Hardware: CPU | Variant: DEFAULT | Status: IDLE" in result.output
196 + assert (
197 + "[s1] e1 | Hardware: CPU | Shape: Standard | Variant: DEFAULT | Status: IDLE"
198 + in result.output
199 + )
200
201 # Test without execution metadata
202 mock_session_state.last_execution = None
@@ -200,6 +212,7 @@ def test_cli_status_running_shows_busy(mock_store, mock_common_state):
212 mock_session_state.endpoint = "e1"
213 mock_session_state.accelerator = "T4"
214 mock_session_state.variant = "GPU"
215 + mock_session_state.machine_shape = "STANDARD"
216 mock_session_state.running = "exec.py"
217 mock_session_state.last_execution = None
218 mock_store.get.return_value = mock_session_state
@@ -208,11 +221,32 @@ def test_cli_status_running_shows_busy(mock_store, mock_common_state):
221 result = runner.invoke(app, ["status", "-s", "s1"])
222 assert result.exit_code == 0
223 assert (
211 - "[s1] e1 | Hardware: T4 | Variant: GPU | Status: BUSY (exec.py)"
224 + "[s1] e1 | Hardware: T4 | Shape: Standard | Variant: GPU | Status: BUSY (exec.py)"
225 in result.output
226 )
227
228
229 +def test_cli_new_high_mem(mock_client, mock_store):
230 + mock_res = MagicMock()
231 + mock_res.__class__ = PostAssignmentResponse
232 + mock_res.runtime_proxy_info.token = "t1"
233 + mock_res.runtime_proxy_info.url = "u1"
234 + mock_res.endpoint = "e1"
235 + mock_client.assign.return_value = mock_res
236 +
237 + result = runner.invoke(app, ["new", "-s", "hm-sess", "--gpu", "A100", "--high-mem"])
238 + assert result.exit_code == 0
239 +
240 + mock_client.assign.assert_called_once()
241 + _, kwargs = mock_client.assign.call_args
242 + from colab_cli.client import Shape
243 +
244 + assert kwargs["shape"] == Shape.HIGH_RAM
245 +
246 + added_state = mock_store.add.call_args[0][0]
247 + assert added_state.machine_shape == "HIGH_RAM"
248 +
249 +
250 def test_cli_session_resolution(mock_store, mock_common_state):
251 mock_session_state = MagicMock()
252 mock_session_state.name = "unique-session"
tests/test_client.py
+88 -1
@@ -16,7 +16,16 @@ import uuid
16 import json
17 import pytest
18 from unittest.mock import MagicMock
19 -from colab_cli.client import Client, Prod, PostAssignmentResponse, Assignment
19 +from colab_cli.client import (
20 + Client,
21 + Prod,
22 + PostAssignmentResponse,
23 + Assignment,
24 + Accelerator,
25 + Shape,
26 + Variant,
27 + resolve_assign_shape,
28 +)
29
30
31 @pytest.fixture
@@ -234,3 +243,81 @@ def test_client_keep_alive_assignment_propagates_http_error(client, mock_session
243
244 with pytest.raises(ColabRequestError):
245 client.keep_alive_assignment("m-s-test-endpoint")
246 +
247 +
248 +def test_client_assign_url_includes_shape_hm(client, mock_session):
249 + get_resp = MagicMock()
250 + get_resp.ok = True
251 + get_resp.text = ")]}'\n" + json.dumps(
252 + {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
253 + )
254 + post_resp = MagicMock()
255 + post_resp.ok = True
256 + post_resp.text = ")]}'\n" + json.dumps(
257 + {
258 + "accelerator": "A100",
259 + "endpoint": "new_endpoint",
260 + "runtimeProxyInfo": {
261 + "token": "proxy_token",
262 + "tokenExpiresInSeconds": 3600,
263 + "url": "http://backend",
264 + },
265 + "variant": 1,
266 + }
267 + )
268 + mock_session.request.side_effect = [get_resp, post_resp]
269 +
270 + client.assign(
271 + uuid.uuid4(),
272 + variant=Variant.GPU,
273 + accelerator=Accelerator.A100,
274 + shape=Shape.HIGH_RAM,
275 + )
276 +
277 + get_url = mock_session.request.call_args_list[0].args[1]
278 + assert "shape=hm" in get_url
279 + post_url = mock_session.request.call_args_list[1].args[1]
280 + assert "shape=hm" in post_url
281 +
282 +
283 +def test_client_assign_url_omits_shape_for_standard(client, mock_session):
284 + get_resp = MagicMock()
285 + get_resp.ok = True
286 + get_resp.text = ")]}'\n" + json.dumps(
287 + {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
288 + )
289 + post_resp = MagicMock()
290 + post_resp.ok = True
291 + post_resp.text = ")]}'\n" + json.dumps(
292 + {
293 + "accelerator": "NONE",
294 + "endpoint": "new_endpoint",
295 + "runtimeProxyInfo": {
296 + "token": "proxy_token",
297 + "tokenExpiresInSeconds": 3600,
298 + "url": "http://backend",
299 + },
300 + "variant": 0,
301 + }
302 + )
303 + mock_session.request.side_effect = [get_resp, post_resp]
304 +
305 + client.assign(uuid.uuid4())
306 +
307 + get_url = mock_session.request.call_args_list[0].args[1]
308 + assert "shape=" not in get_url
309 +
310 +
311 +@pytest.mark.parametrize(
312 + "accelerator,high_mem,expected",
313 + [
314 + (Accelerator.T4, True, Shape.HIGH_RAM),
315 + (Accelerator.A100, True, Shape.HIGH_RAM),
316 + (Accelerator.NONE, True, Shape.HIGH_RAM),
317 + (Accelerator.L4, True, None),
318 + (Accelerator.V5E1, True, None),
319 + (Accelerator.T4, False, None),
320 + ],
321 +)
322 +def test_resolve_assign_shape(accelerator, high_mem, expected):
323 + assert resolve_assign_shape(accelerator, high_mem=high_mem) == expected
tests/test_run.py
+27
@@ -115,6 +115,33 @@ def test_run_basic_flow(
115 mock_client.unassign.assert_called_once_with("ep-123")
116
117
118 +def test_run_high_mem_passes_shape_to_assign(
119 + mock_client,
120 + mock_store,
121 + mock_runtime_class,
122 + mock_spawn_keep_alive,
123 + assign_response,
124 + script_path,
125 +):
126 + mock_client.assign.return_value = assign_response
127 + mock_runtime_class.return_value.execute_code.return_value = []
128 +
129 + persisted = {}
130 + mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
131 + mock_store.get.side_effect = lambda name: persisted.get("s")
132 +
133 + result = runner.invoke(
134 + app, ["run", "--gpu", "A100", "--high-mem", str(script_path)]
135 + )
136 + assert result.exit_code == 0, result.output
137 +
138 + from colab_cli.client import Shape
139 +
140 + _, kwargs = mock_client.assign.call_args
141 + assert kwargs["shape"] == Shape.HIGH_RAM
142 + assert persisted["s"].machine_shape == "HIGH_RAM"
143 +
144 +
145 # ---------------------------------------------------------------------------
146 # --keep flag
147 # ---------------------------------------------------------------------------
tests/test_ssh_autocreate.py
+19
@@ -114,10 +114,29 @@ def test_bare_ssh_session_resolution(
114 new.assert_called_once()
115 assert new.call_args.kwargs.get("gpu") == gpu
116 assert new.call_args.kwargs.get("tpu") is None
117 + assert new.call_args.kwargs.get("high_mem") is False
118 else:
119 new.assert_not_called()
120
121
122 +def test_bare_ssh_forwards_high_mem_on_autocreate(mock_common_state, mocker):
123 + mock_common_state.store.list.return_value = {}
124 + sess = _make_session()
125 + mock_common_state.store.get.return_value = sess
126 + new = mocker.patch("colab_cli.commands.session.new")
127 + _patch_interactive(mocker)
128 +
129 + result = runner.invoke(app, ["ssh", "--gpu", "A100", "--high-mem"])
130 + assert result.exit_code == 0
131 + new.assert_called_once()
132 + assert new.call_args.kwargs == {
133 + "session": new.call_args.kwargs["session"],
134 + "gpu": "A100",
135 + "tpu": None,
136 + "high_mem": True,
137 + }
138 +
139 +
140 # --- --proxy-mode -s NAME: create-if-missing / reuse ------------------------
141
142