Skip to content

Commit 558e1f9

Browse files
committed
feat: add venv parameter for Python virtualenv activation
Allows instances to run in a pre-existing virtualenv instead of the worker's Python environment. Users are responsible for creating and maintaining the venv on shared filesystem. Changes: - Add `venv` field to InstanceSubmissionRequest, Instance, DesiredInstance - Add `venv` column to instances table in SQLite - Worker activates venv before command: `source {venv}/bin/activate && {cmd}` - Server validates: must be absolute path, no '..' allowed - CLI: `--venv /path/to/venv` flag - Python API: `venv` parameter in submit() Example: pylet submit "python train.py" --venv /home/user/torch-env
1 parent ef044a2 commit 558e1f9

11 files changed

Lines changed: 1403 additions & 18 deletions

File tree

pylet/_sync_api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def submit(
8484
exclusive: bool = True,
8585
labels: Optional[Dict[str, str]] = None,
8686
env: Optional[Dict[str, str]] = None,
87+
# Venv support
88+
venv: Optional[str] = None,
8789
) -> Instance:
8890
"""
8991
Submit a new instance.
@@ -99,6 +101,7 @@ def submit(
99101
exclusive: If False, GPUs don't block allocation pool (default True)
100102
labels: Custom metadata dict
101103
env: Environment variables to set
104+
venv: Path to pre-existing virtualenv (must be absolute path)
102105
103106
Returns:
104107
Instance handle
@@ -120,6 +123,13 @@ def submit(
120123
exclusive=False,
121124
labels={"type": "sllm-store"},
122125
)
126+
127+
# Venv example
128+
instance = pylet.submit(
129+
"python train.py",
130+
venv="/home/user/my-venv",
131+
gpu=1,
132+
)
123133
"""
124134
client = _get_client()
125135
address = _get_head_address()
@@ -145,6 +155,8 @@ def submit(
145155
submission["target_worker"] = target_worker
146156
if gpu_indices is not None:
147157
submission["gpu_indices"] = gpu_indices
158+
if venv is not None:
159+
submission["venv"] = venv
148160

149161
# Submit instance
150162
response = client.post(f"{address}/instances", json=submission)

pylet/aio/__init__.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ async def main():
1313
"""
1414

1515
import shlex
16-
from typing import List, Optional, Union
16+
from typing import Dict, List, Optional, Union
1717

1818
import httpx
1919

@@ -97,16 +97,30 @@ async def submit(
9797
gpu: int = 0,
9898
cpu: int = 1,
9999
memory: int = 512,
100+
# SLLM support parameters
101+
target_worker: Optional[str] = None,
102+
gpu_indices: Optional[List[int]] = None,
103+
exclusive: bool = True,
104+
labels: Optional[Dict[str, str]] = None,
105+
env: Optional[Dict[str, str]] = None,
106+
# Venv support
107+
venv: Optional[str] = None,
100108
) -> Instance:
101109
"""
102110
Submit a new instance.
103111
104112
Args:
105113
command: Shell command string, or list of args (auto shell-escaped)
106114
name: Optional instance name for service discovery
107-
gpu: GPU units required (default 0)
115+
gpu: GPU units required (default 0, ignored if gpu_indices specified)
108116
cpu: CPU cores required (default 1)
109117
memory: Memory in MB required (default 512)
118+
target_worker: Place on specific worker node
119+
gpu_indices: Request specific physical GPU indices
120+
exclusive: If False, GPUs don't block allocation pool (default True)
121+
labels: Custom metadata dict
122+
env: Environment variables to set
123+
venv: Path to pre-existing virtualenv (must be absolute path)
110124
111125
Returns:
112126
Instance handle
@@ -122,19 +136,28 @@ async def submit(
122136
if isinstance(command, list):
123137
command = shlex.join(command)
124138

125-
# Submit instance
126-
response = await client.post(
127-
f"{address}/instances",
128-
json={
129-
"command": command,
130-
"resource_requirements": {
131-
"cpu_cores": cpu,
132-
"gpu_units": gpu,
133-
"memory_mb": memory,
134-
},
135-
"name": name,
139+
# Build submission request
140+
submission = {
141+
"command": command,
142+
"resource_requirements": {
143+
"cpu_cores": cpu,
144+
"gpu_units": gpu,
145+
"memory_mb": memory,
136146
},
137-
)
147+
"name": name,
148+
"exclusive": exclusive,
149+
"labels": labels or {},
150+
"env": env or {},
151+
}
152+
if target_worker is not None:
153+
submission["target_worker"] = target_worker
154+
if gpu_indices is not None:
155+
submission["gpu_indices"] = gpu_indices
156+
if venv is not None:
157+
submission["venv"] = venv
158+
159+
# Submit instance
160+
response = await client.post(f"{address}/instances", json=submission)
138161
response.raise_for_status()
139162

140163
instance_id = response.json()["instance_id"]

pylet/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ def start(head, cpu_cores, gpu_units, memory_mb):
8383
@click.option("--exclusive/--no-exclusive", default=True, help="GPU exclusivity mode.")
8484
@click.option("--label", multiple=True, help="Labels (key=value).")
8585
@click.option("--env", "env_vars", multiple=True, help="Env vars (key=value).")
86+
# Venv support
87+
@click.option("--venv", default=None, help="Path to pre-existing virtualenv (must be absolute).")
8688
def submit(command, config, cpu_cores, gpu_units, memory_mb, name,
87-
target_worker, gpu_indices, exclusive, label, env_vars):
89+
target_worker, gpu_indices, exclusive, label, env_vars, venv):
8890
"""Submit a new instance to the PyLet cluster.
8991
9092
Precedence (highest wins): CLI args > Config file > Defaults
@@ -192,6 +194,7 @@ async def submit_instance():
192194
exclusive=exclusive,
193195
labels=labels if labels else None,
194196
env=env if env else None,
197+
venv=venv,
195198
)
196199
click.echo(f"Instance submitted with ID: {instance_id}")
197200
except Exception as e:

pylet/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ async def submit_instance(
3131
exclusive: bool = True,
3232
labels: Optional[Dict[str, str]] = None,
3333
env: Optional[Dict[str, str]] = None,
34+
# Venv support
35+
venv: Optional[str] = None,
3436
) -> str:
3537
"""Submit a new instance for execution."""
3638
submission_data: Dict[str, Any] = {
@@ -46,6 +48,8 @@ async def submit_instance(
4648
submission_data["target_worker"] = target_worker
4749
if gpu_indices is not None:
4850
submission_data["gpu_indices"] = gpu_indices
51+
if venv is not None:
52+
submission_data["venv"] = venv
4953

5054
response = await self.client.post(
5155
f"{self.api_server_url}/instances", json=submission_data

pylet/controller.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ async def _get_desired_instances(self, worker_id: str) -> List[DesiredInstance]:
214214
command=row["command"],
215215
gpu_indices=gpu_indices,
216216
env=env,
217+
venv=row.get("venv"),
217218
expected_status=row["status"],
218219
)
219220
)
@@ -360,6 +361,8 @@ async def submit_instance(
360361
exclusive: bool = True,
361362
labels: Optional[Dict[str, str]] = None,
362363
env: Optional[Dict[str, str]] = None,
364+
# Venv support
365+
venv: Optional[str] = None,
363366
) -> str:
364367
"""Submit a new instance for execution."""
365368
async with self.lock:
@@ -391,6 +394,7 @@ async def submit_instance(
391394
env=env or {},
392395
target_worker=target_worker,
393396
gpu_indices=gpu_indices,
397+
venv=venv,
394398
)
395399

396400
logger.info(

pylet/db.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ async def _create_tables(self) -> None:
8686
env TEXT, -- JSON object
8787
target_worker TEXT, -- Placement constraint
8888
requested_gpu_indices TEXT, -- JSON array
89+
-- Venv support
90+
venv TEXT, -- Absolute path to virtualenv
8991
-- Execution
9092
exit_code INTEGER,
9193
stdout_log TEXT,
@@ -317,16 +319,18 @@ async def insert_instance(
317319
env: Optional[Dict[str, str]] = None,
318320
target_worker: Optional[str] = None,
319321
gpu_indices: Optional[List[int]] = None,
322+
# Venv support
323+
venv: Optional[str] = None,
320324
) -> None:
321325
"""Insert a new instance."""
322326
async with self.transaction():
323327
await self._conn.execute(
324328
"""
325329
INSERT INTO instances (
326330
id, name, command, status, cpu_cores, gpu_units, memory_mb,
327-
exclusive, labels, env, target_worker, requested_gpu_indices
331+
exclusive, labels, env, target_worker, requested_gpu_indices, venv
328332
)
329-
VALUES (?, ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?)
333+
VALUES (?, ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?)
330334
""",
331335
(
332336
instance_id, name, command, cpu_cores, gpu_units, memory_mb,
@@ -335,6 +339,7 @@ async def insert_instance(
335339
json.dumps(env) if env else None,
336340
target_worker,
337341
json.dumps(gpu_indices) if gpu_indices else None,
342+
venv,
338343
),
339344
)
340345

pylet/schemas.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ class Instance(BaseModel):
108108
exclusive: bool = True
109109
labels: Dict[str, str] = Field(default_factory=dict)
110110
env: Dict[str, str] = Field(default_factory=dict)
111+
# Venv support
112+
venv: Optional[str] = None # Path to pre-existing virtualenv (must be absolute path)
111113

112114

113115
def get_display_status(status: InstanceStatus, cancellation_requested_at: Optional[datetime]) -> str:
@@ -146,6 +148,8 @@ class InstanceSubmissionRequest(BaseModel):
146148
exclusive: bool = True # GPU exclusivity mode
147149
labels: Dict[str, str] = Field(default_factory=dict) # Custom metadata
148150
env: Dict[str, str] = Field(default_factory=dict) # Environment variables
151+
# Venv support
152+
venv: Optional[str] = None # Path to pre-existing virtualenv (must be absolute path)
149153

150154

151155
class WorkerRegistrationRequest(BaseModel):
@@ -187,6 +191,7 @@ class DesiredInstance(BaseModel):
187191
command: str
188192
gpu_indices: List[int] = []
189193
env: Dict[str, str] = {}
194+
venv: Optional[str] = None # Path to pre-existing virtualenv
190195
expected_status: str = "ASSIGNED" # ASSIGNED means "please start"
191196

192197

pylet/server.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class InstanceSubmissionRequest(BaseModel):
4646
exclusive: bool = True
4747
labels: Dict[str, str] = Field(default_factory=dict)
4848
env: Dict[str, str] = Field(default_factory=dict)
49+
# Venv support
50+
venv: Optional[str] = None # Path to pre-existing virtualenv (must be absolute path)
4951

5052

5153
class WorkerRegistrationRequest(BaseModel):
@@ -158,6 +160,8 @@ async def _build_instance_response(inst: Dict) -> Dict:
158160
"labels": _parse_json_field(inst.get("labels"), {}),
159161
"env": _parse_json_field(inst.get("env"), {}),
160162
"target_worker": inst.get("target_worker"),
163+
# Venv support
164+
"venv": inst.get("venv"),
161165
}
162166

163167

@@ -227,6 +231,14 @@ async def _validate_submission(request: InstanceSubmissionRequest) -> None:
227231
if conflicts:
228232
logger.warning(f"User env vars {conflicts} will be overridden by Pylet")
229233

234+
# 5. Validate venv path if specified
235+
if request.venv is not None:
236+
if not request.venv.startswith("/"):
237+
raise ValueError("venv must be an absolute path")
238+
if ".." in request.venv:
239+
raise ValueError("venv path cannot contain '..'")
240+
# Note: We don't check if the path exists - that happens at runtime on the worker
241+
230242

231243
@app.post("/instances")
232244
async def submit_instance(request: InstanceSubmissionRequest):
@@ -244,6 +256,7 @@ async def submit_instance(request: InstanceSubmissionRequest):
244256
exclusive=request.exclusive,
245257
labels=request.labels,
246258
env=request.env,
259+
venv=request.venv,
247260
)
248261
logger.info(
249262
f"Client submitted instance {instance_id} ('{request.name}') "

pylet/worker.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,19 @@ async def _start_instance(self, inst: DesiredInstance) -> None:
418418
# Uses trap "" PIPE to let task continue if sidecar crashes
419419
log_dir = str(config.LOG_DIR)
420420
escaped_cmd = inst.command.replace("'", "'\"'\"'") # Escape single quotes
421+
422+
# Activate venv if specified (before user command, not sidecar)
423+
if inst.venv:
424+
# Use double quotes inside the single-quoted bash command to handle spaces
425+
# Escape any double quotes or backslashes in the path
426+
safe_venv = inst.venv.replace("\\", "\\\\").replace('"', '\\"')
427+
venv_activate = f'source "{safe_venv}/bin/activate" && '
428+
else:
429+
venv_activate = ""
430+
421431
shell_cmd = (
422432
f"/bin/bash -c 'set -o pipefail; trap \"\" PIPE; "
423-
f"({escaped_cmd}) 2>&1 | "
433+
f"({venv_activate}{escaped_cmd}) 2>&1 | "
424434
f"python3 -m pylet.log_sidecar {shlex.quote(log_dir)} "
425435
f"{shlex.quote(inst.instance_id)}'"
426436
)

0 commit comments

Comments
 (0)