Skip to content

Commit 1db5df3

Browse files
abrichrclaude
andauthored
fix: strip heavy ML/CUDA deps for lightweight worker installs (#25)
* fix: use uv sync --no-dev --inexact to skip heavy optional deps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip heavy ML/CUDA deps from pyproject.toml before uv sync Worker fails on repos with CUDA/PyTorch optional deps (500MB+). Strips known heavy packages from [dependencies] and removes [project.optional-dependencies] entirely. Combined with --no-dev --inexact, the worker installs only what's needed to run tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e116477 commit 1db5df3

File tree

2 files changed

+344
-4
lines changed

2 files changed

+344
-4
lines changed

apps/worker/src/__tests__/test-runner.test.ts

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
33
import { join } from 'path'
44
import { tmpdir } from 'os'
55
import { existsSync, readFileSync } from 'fs'
6-
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests, stripLocalUvSources } from '../test-runner.js'
6+
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests, stripLocalUvSources, stripHeavyPyDeps } from '../test-runner.js'
77

88
// Helper: create a temp directory with specific files
99
function createTempDir(): string {
@@ -351,6 +351,190 @@ pkg-c = { path = "/absolute/path/to/pkg-c" }
351351
})
352352
})
353353

354+
describe('stripHeavyPyDeps', () => {
355+
let tempDir: string
356+
357+
beforeEach(() => {
358+
tempDir = createTempDir()
359+
})
360+
361+
afterEach(() => {
362+
rmSync(tempDir, { recursive: true, force: true })
363+
})
364+
365+
it('strips heavy packages from base dependencies', () => {
366+
const pyprojectContent = `[project]
367+
name = "test-project"
368+
dependencies = [
369+
"requests>=2.28.0",
370+
"torch>=2.8.0",
371+
"torchvision>=0.24.1",
372+
"pillow>=10.0.0",
373+
"open-clip-torch>=2.20.0",
374+
"transformers>=4.57.3",
375+
"bitsandbytes>=0.41.0",
376+
"peft>=0.18.0",
377+
]
378+
379+
[tool.hatch.build.targets.wheel]
380+
packages = ["my_package"]
381+
`
382+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
383+
touchFile(tempDir, 'uv.lock', 'some lock content')
384+
385+
stripHeavyPyDeps(tempDir)
386+
387+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
388+
// Heavy packages should be removed
389+
expect(result).not.toContain('torch>=2.8.0')
390+
expect(result).not.toContain('torchvision')
391+
expect(result).not.toContain('open-clip-torch')
392+
expect(result).not.toContain('transformers')
393+
expect(result).not.toContain('bitsandbytes')
394+
expect(result).not.toContain('peft')
395+
// Lightweight packages should be preserved
396+
expect(result).toContain('requests>=2.28.0')
397+
expect(result).toContain('pillow>=10.0.0')
398+
// Other sections should be preserved
399+
expect(result).toContain('[project]')
400+
expect(result).toContain('[tool.hatch.build.targets.wheel]')
401+
// uv.lock should be deleted
402+
expect(existsSync(join(tempDir, 'uv.lock'))).toBe(false)
403+
})
404+
405+
it('strips entire [project.optional-dependencies] section', () => {
406+
const pyprojectContent = `[project]
407+
name = "test-project"
408+
dependencies = [
409+
"requests>=2.28.0",
410+
]
411+
412+
[project.optional-dependencies]
413+
dev = [
414+
"pytest>=8.0.0",
415+
]
416+
training = [
417+
"torch>=2.8.0",
418+
"trl>=0.12.0",
419+
]
420+
azure = [
421+
"azure-ai-ml>=1.12.0",
422+
]
423+
424+
[tool.hatch.build.targets.wheel]
425+
packages = ["my_package"]
426+
`
427+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
428+
429+
stripHeavyPyDeps(tempDir)
430+
431+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
432+
// Entire optional-dependencies section should be removed
433+
expect(result).not.toContain('[project.optional-dependencies]')
434+
expect(result).not.toContain('pytest>=8.0.0')
435+
expect(result).not.toContain('trl>=0.12.0')
436+
expect(result).not.toContain('azure-ai-ml')
437+
// Base deps and other sections should be preserved
438+
expect(result).toContain('requests>=2.28.0')
439+
expect(result).toContain('[tool.hatch.build.targets.wheel]')
440+
})
441+
442+
it('handles nvidia-* prefix packages', () => {
443+
const pyprojectContent = `[project]
444+
name = "test-project"
445+
dependencies = [
446+
"requests>=2.28.0",
447+
"nvidia-cublas-cu12>=12.1.0",
448+
"nvidia-cuda-runtime-cu12>=12.0",
449+
]
450+
`
451+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
452+
453+
stripHeavyPyDeps(tempDir)
454+
455+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
456+
expect(result).not.toContain('nvidia-cublas')
457+
expect(result).not.toContain('nvidia-cuda-runtime')
458+
expect(result).toContain('requests>=2.28.0')
459+
})
460+
461+
it('preserves pyproject.toml with no heavy deps', () => {
462+
const pyprojectContent = `[project]
463+
name = "test-project"
464+
dependencies = [
465+
"requests>=2.28.0",
466+
"pillow>=10.0.0",
467+
]
468+
`
469+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
470+
touchFile(tempDir, 'uv.lock', 'some lock content')
471+
472+
stripHeavyPyDeps(tempDir)
473+
474+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
475+
expect(result).toBe(pyprojectContent)
476+
// uv.lock should NOT be deleted when no changes were made
477+
expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true)
478+
})
479+
480+
it('handles openadapt-evals-like pyproject.toml', () => {
481+
const pyprojectContent = `[project]
482+
name = "openadapt-evals"
483+
version = "0.46.0"
484+
dependencies = [
485+
"open-clip-torch>=2.20.0",
486+
"pillow>=10.0.0",
487+
"pydantic-settings>=2.0.0",
488+
"requests>=2.28.0",
489+
"openai>=1.0.0",
490+
"anthropic>=0.76.0",
491+
"openadapt-ml>=0.11.0",
492+
]
493+
494+
[project.optional-dependencies]
495+
dev = [
496+
"pytest>=8.0.0",
497+
"ruff>=0.1.0",
498+
]
499+
training = [
500+
"imagehash>=4.3.0",
501+
]
502+
verl = [
503+
"verl>=0.3.0",
504+
]
505+
506+
[tool.uv.sources]
507+
openadapt-ml = { path = "../openadapt-ml", editable = true }
508+
509+
[tool.hatch.build.targets.wheel]
510+
packages = ["openadapt_evals"]
511+
`
512+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
513+
514+
stripHeavyPyDeps(tempDir)
515+
516+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
517+
// Heavy base dep should be stripped
518+
expect(result).not.toContain('open-clip-torch')
519+
// Optional deps section entirely removed
520+
expect(result).not.toContain('[project.optional-dependencies]')
521+
expect(result).not.toContain('verl')
522+
// Lightweight base deps preserved
523+
expect(result).toContain('pillow>=10.0.0')
524+
expect(result).toContain('requests>=2.28.0')
525+
expect(result).toContain('openai>=1.0.0')
526+
expect(result).toContain('anthropic>=0.76.0')
527+
expect(result).toContain('openadapt-ml>=0.11.0')
528+
// Other sections preserved
529+
expect(result).toContain('[tool.uv.sources]')
530+
expect(result).toContain('[tool.hatch.build.targets.wheel]')
531+
})
532+
533+
it('does nothing when pyproject.toml does not exist', () => {
534+
expect(() => stripHeavyPyDeps(tempDir)).not.toThrow()
535+
})
536+
})
537+
354538
describe('runTests with real commands', () => {
355539
let tempDir: string
356540

apps/worker/src/test-runner.ts

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,159 @@ export function stripLocalUvSources(workDir: string): void {
206206
}
207207
}
208208

209+
/**
210+
* Known heavy Python packages that should be stripped from dependencies
211+
* on the worker. These packages (CUDA, PyTorch, large ML frameworks) are
212+
* 100MB-2GB each and are not needed to run tests.
213+
*
214+
* Patterns are matched against the package name portion of dependency lines
215+
* (before any version specifier). Case-insensitive.
216+
*/
217+
const HEAVY_PY_PACKAGES = [
218+
'torch',
219+
'torchvision',
220+
'torchaudio',
221+
'open-clip-torch',
222+
'bitsandbytes',
223+
'triton',
224+
'nvidia-', // nvidia-cublas-cu12, nvidia-cuda-runtime-cu12, etc.
225+
'cu12', // standalone CUDA 12 packages
226+
'cu11', // standalone CUDA 11 packages
227+
'xformers',
228+
'flash-attn',
229+
'deepspeed',
230+
'apex',
231+
'vllm',
232+
'transformers',
233+
'accelerate',
234+
'peft',
235+
'safetensors',
236+
'sentencepiece',
237+
'tokenizers',
238+
]
239+
240+
/**
241+
* Check if a dependency line references a heavy package.
242+
*
243+
* Dependency lines look like:
244+
* "torch>=2.8.0"
245+
* "open-clip-torch>=2.20.0"
246+
* "nvidia-cublas-cu12>=12.1.0"
247+
*
248+
* We match the package name (everything before `>=`, `==`, `~=`, `<`, `>`, `[`, etc.)
249+
* against the HEAVY_PY_PACKAGES patterns.
250+
*/
251+
function isHeavyDep(depLine: string): boolean {
252+
const trimmed = depLine.trim().replace(/^["']|["'],?\s*$/g, '')
253+
if (!trimmed || trimmed.startsWith('#')) return false
254+
255+
// Extract package name (before version specifier)
256+
const pkgName = trimmed.split(/[>=<!~\[;]/)[0].trim().toLowerCase()
257+
if (!pkgName) return false
258+
259+
return HEAVY_PY_PACKAGES.some(pattern => {
260+
const p = pattern.toLowerCase()
261+
// If pattern ends with '-', match as prefix
262+
if (p.endsWith('-')) {
263+
return pkgName.startsWith(p)
264+
}
265+
return pkgName === p
266+
})
267+
}
268+
269+
/**
270+
* Strip heavy ML/CUDA dependencies from pyproject.toml to keep installs lightweight.
271+
*
272+
* The Wright worker runs tests, not training. Heavy packages like PyTorch (2GB+),
273+
* CUDA libraries, and large ML frameworks slow down installs, eat disk space, and
274+
* may fail entirely on non-GPU containers.
275+
*
276+
* This function:
277+
* 1. Removes known heavy packages from `[project] dependencies = [...]`
278+
* 2. Removes the entire `[project.optional-dependencies]` section (all groups).
279+
* Optional deps are already skipped by `uv sync --no-dev`, but some groups
280+
* may be pulled in transitively or via `all = [...]` meta-groups.
281+
*
282+
* Combined with `--inexact`, missing transitive deps from stripped packages are
283+
* tolerated — uv will install what it can and skip the rest.
284+
*/
285+
export function stripHeavyPyDeps(workDir: string): void {
286+
const pyprojectPath = join(workDir, 'pyproject.toml')
287+
if (!existsSync(pyprojectPath)) return
288+
289+
const content = readFileSync(pyprojectPath, 'utf-8')
290+
const lines = content.split('\n')
291+
const outputLines: string[] = []
292+
let inDeps = false
293+
let inOptDeps = false
294+
let inOptGroup = false
295+
let strippedAny = false
296+
let bracketDepth = 0
297+
298+
for (let i = 0; i < lines.length; i++) {
299+
const line = lines[i]
300+
const trimmed = line.trim()
301+
302+
// Track [project.optional-dependencies] section — skip it entirely
303+
if (trimmed === '[project.optional-dependencies]') {
304+
inOptDeps = true
305+
inOptGroup = false
306+
strippedAny = true
307+
console.log('[test-runner] Stripping [project.optional-dependencies] section')
308+
continue
309+
}
310+
311+
// If we're in optional-dependencies, skip until we hit a new top-level section
312+
if (inOptDeps) {
313+
if (trimmed.startsWith('[') && trimmed.endsWith(']') && trimmed !== '[project.optional-dependencies]') {
314+
inOptDeps = false
315+
// Fall through to process this line normally
316+
} else {
317+
continue
318+
}
319+
}
320+
321+
// Track `dependencies = [` in [project] section
322+
if (/^dependencies\s*=\s*\[/.test(trimmed)) {
323+
inDeps = true
324+
bracketDepth = (line.match(/\[/g) || []).length - (line.match(/\]/g) || []).length
325+
outputLines.push(line)
326+
continue
327+
}
328+
329+
if (inDeps) {
330+
// Track bracket depth (handles multi-line arrays)
331+
bracketDepth += (line.match(/\[/g) || []).length - (line.match(/\]/g) || []).length
332+
if (bracketDepth <= 0) {
333+
inDeps = false
334+
outputLines.push(line)
335+
continue
336+
}
337+
338+
// Check if this dependency line is heavy
339+
if (isHeavyDep(trimmed)) {
340+
strippedAny = true
341+
console.log(`[test-runner] Stripped heavy dependency: ${trimmed}`)
342+
continue
343+
}
344+
}
345+
346+
outputLines.push(line)
347+
}
348+
349+
if (strippedAny) {
350+
writeFileSync(pyprojectPath, outputLines.join('\n'))
351+
console.log('[test-runner] Removed heavy dependencies from pyproject.toml')
352+
353+
// Delete uv.lock so uv regenerates it without the heavy deps
354+
const lockPath = join(workDir, 'uv.lock')
355+
if (existsSync(lockPath)) {
356+
unlinkSync(lockPath)
357+
console.log('[test-runner] Removed stale uv.lock (will regenerate)')
358+
}
359+
}
360+
}
361+
209362
/**
210363
* Install dependencies using the detected package manager.
211364
*/
@@ -215,7 +368,7 @@ export function installDependencies(workDir: string, pm: PackageManager): void {
215368
pnpm: 'pnpm install',
216369
yarn: 'yarn install',
217370
pip: 'pip install -e .',
218-
uv: 'uv sync',
371+
uv: 'uv sync --no-dev --inexact',
219372
poetry: 'poetry install',
220373
cargo: 'cargo build',
221374
go: 'go mod download',
@@ -225,10 +378,13 @@ export function installDependencies(workDir: string, pm: PackageManager): void {
225378
const cmd = commands[pm]
226379
if (!cmd) return
227380

228-
// For uv projects, strip local path sources from pyproject.toml that reference
229-
// sibling directories (e.g. "../openadapt-ml") which won't exist on the worker.
381+
// For uv projects, strip local path sources and heavy ML dependencies from
382+
// pyproject.toml. Local paths reference sibling directories (e.g. "../openadapt-ml")
383+
// that won't exist on the worker. Heavy deps (PyTorch, CUDA, etc.) are 100MB-2GB
384+
// each and not needed to run tests.
230385
if (pm === 'uv') {
231386
stripLocalUvSources(workDir)
387+
stripHeavyPyDeps(workDir)
232388
}
233389

234390
console.log(`[test-runner] Installing dependencies with ${pm}: ${cmd}`)

0 commit comments

Comments
 (0)