Skip to content

Commit e116477

Browse files
abrichrclaude
andauthored
fix: strip local uv path sources before installing dependencies (#24)
* fix: strip local uv path sources before installing dependencies uv sync fails on Fly.io workers because pyproject.toml contains [tool.uv.sources] with local path references (e.g., path = "../openadapt-ml") that don't exist in the container. The packages are still listed as normal dependencies and resolve from PyPI without the source override. Strips path sources from pyproject.toml and deletes stale uv.lock before running uv sync. Preserves git and URL sources. 5 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: test should check source override removed, not dependency itself 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 bd56e54 commit e116477

2 files changed

Lines changed: 186 additions & 2 deletions

File tree

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

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
22
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
33
import { join } from 'path'
44
import { tmpdir } from 'os'
5-
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests } from '../test-runner.js'
5+
import { existsSync, readFileSync } from 'fs'
6+
import { detectTestRunner, detectPackageManager, detectMonorepo, runTests, stripLocalUvSources } from '../test-runner.js'
67

78
// Helper: create a temp directory with specific files
89
function createTempDir(): string {
@@ -244,6 +245,112 @@ describe('detectMonorepo', () => {
244245
})
245246
})
246247

248+
describe('stripLocalUvSources', () => {
249+
let tempDir: string
250+
251+
beforeEach(() => {
252+
tempDir = createTempDir()
253+
})
254+
255+
afterEach(() => {
256+
rmSync(tempDir, { recursive: true, force: true })
257+
})
258+
259+
it('strips path-based source entries from pyproject.toml', () => {
260+
const pyprojectContent = `[project]
261+
name = "test-project"
262+
dependencies = [
263+
"openadapt-ml>=0.11.0",
264+
"openadapt-consilium>=0.3.2",
265+
]
266+
267+
[tool.uv.sources]
268+
openadapt-consilium = { git = "https://github.com/OpenAdaptAI/openadapt-consilium.git" }
269+
openadapt-ml = { path = "../openadapt-ml", editable = true }
270+
271+
[tool.hatch.build.targets.wheel]
272+
packages = ["my_package"]
273+
`
274+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
275+
touchFile(tempDir, 'uv.lock', 'some lock content')
276+
277+
stripLocalUvSources(tempDir)
278+
279+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
280+
// Path-based source override should be removed
281+
expect(result).not.toContain('path = "../openadapt-ml"')
282+
// But the dependency itself in [project.dependencies] should remain
283+
expect(result).toContain('openadapt-ml>=0.11.0')
284+
// Git-based entry should be preserved
285+
expect(result).toContain('openadapt-consilium')
286+
expect(result).toContain('git = "https://github.com/')
287+
// Other sections should be preserved
288+
expect(result).toContain('[project]')
289+
expect(result).toContain('[tool.hatch.build.targets.wheel]')
290+
// uv.lock should be deleted
291+
expect(existsSync(join(tempDir, 'uv.lock'))).toBe(false)
292+
})
293+
294+
it('preserves pyproject.toml without [tool.uv.sources]', () => {
295+
const pyprojectContent = `[project]
296+
name = "test-project"
297+
dependencies = ["requests>=2.28.0"]
298+
`
299+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
300+
touchFile(tempDir, 'uv.lock', 'some lock content')
301+
302+
stripLocalUvSources(tempDir)
303+
304+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
305+
expect(result).toBe(pyprojectContent)
306+
// uv.lock should NOT be deleted when no changes were made
307+
expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true)
308+
})
309+
310+
it('preserves pyproject.toml with only git sources', () => {
311+
const pyprojectContent = `[project]
312+
name = "test-project"
313+
314+
[tool.uv.sources]
315+
consilium = { git = "https://github.com/example/consilium.git" }
316+
`
317+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
318+
touchFile(tempDir, 'uv.lock', 'some lock content')
319+
320+
stripLocalUvSources(tempDir)
321+
322+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
323+
expect(result).toBe(pyprojectContent)
324+
// uv.lock should NOT be deleted when no path sources were stripped
325+
expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true)
326+
})
327+
328+
it('handles multiple path-based sources', () => {
329+
const pyprojectContent = `[project]
330+
name = "test-project"
331+
332+
[tool.uv.sources]
333+
pkg-a = { path = "../pkg-a", editable = true }
334+
pkg-b = { git = "https://github.com/example/pkg-b.git" }
335+
pkg-c = { path = "/absolute/path/to/pkg-c" }
336+
`
337+
touchFile(tempDir, 'pyproject.toml', pyprojectContent)
338+
339+
stripLocalUvSources(tempDir)
340+
341+
const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8')
342+
expect(result).not.toContain('pkg-a')
343+
expect(result).not.toContain('pkg-c')
344+
expect(result).toContain('pkg-b')
345+
expect(result).toContain('git = "https://github.com/')
346+
})
347+
348+
it('does nothing when pyproject.toml does not exist', () => {
349+
// Should not throw
350+
expect(() => stripLocalUvSources(tempDir)).not.toThrow()
351+
})
352+
})
353+
247354
describe('runTests with real commands', () => {
248355
let tempDir: string
249356

apps/worker/src/test-runner.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from 'child_process'
2-
import { existsSync, readFileSync } from 'fs'
2+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
33
import { join } from 'path'
44
import type { TestRunner, PackageManager, TestResults, TestFailure } from '@wright/shared'
55

@@ -135,6 +135,77 @@ export function detectPackageManager(workDir: string): PackageManager {
135135
return 'none'
136136
}
137137

138+
/**
139+
* Strip local path-based source overrides from pyproject.toml's [tool.uv.sources].
140+
*
141+
* Many projects use `[tool.uv.sources]` to point dependencies at local sibling
142+
* directories for development (e.g. `openadapt-ml = { path = "../openadapt-ml", editable = true }`).
143+
* These paths don't exist on the worker, causing `uv sync` to fail immediately.
144+
*
145+
* This function removes any source entries that use `path = "..."` (local
146+
* filesystem references) while preserving git/url sources. It also removes the
147+
* stale `uv.lock` so uv regenerates it with PyPI-resolved versions.
148+
*
149+
* The packages themselves are still listed as regular dependencies (e.g.
150+
* `openadapt-ml>=0.11.0`) and will resolve from PyPI without the source override.
151+
*/
152+
export function stripLocalUvSources(workDir: string): void {
153+
const pyprojectPath = join(workDir, 'pyproject.toml')
154+
if (!existsSync(pyprojectPath)) return
155+
156+
const content = readFileSync(pyprojectPath, 'utf-8')
157+
158+
// Check if there's a [tool.uv.sources] section with path-based entries
159+
if (!content.includes('[tool.uv.sources]')) return
160+
161+
const lines = content.split('\n')
162+
const outputLines: string[] = []
163+
let inUvSources = false
164+
let strippedAny = false
165+
166+
for (let i = 0; i < lines.length; i++) {
167+
const line = lines[i]
168+
const trimmed = line.trim()
169+
170+
// Detect start of [tool.uv.sources] section
171+
if (trimmed === '[tool.uv.sources]') {
172+
inUvSources = true
173+
outputLines.push(line)
174+
continue
175+
}
176+
177+
// Detect start of any other section (ends [tool.uv.sources])
178+
if (inUvSources && trimmed.startsWith('[') && trimmed.endsWith(']')) {
179+
inUvSources = false
180+
}
181+
182+
if (inUvSources) {
183+
// Skip lines that contain `path =` (local filesystem source)
184+
// These look like: `package-name = { path = "../some-dir", editable = true }`
185+
if (/\bpath\s*=\s*"/.test(line)) {
186+
strippedAny = true
187+
console.log(`[test-runner] Stripped local uv source: ${trimmed}`)
188+
continue
189+
}
190+
}
191+
192+
outputLines.push(line)
193+
}
194+
195+
if (strippedAny) {
196+
writeFileSync(pyprojectPath, outputLines.join('\n'))
197+
console.log('[test-runner] Removed local path sources from pyproject.toml')
198+
199+
// Delete uv.lock so uv regenerates it without the local sources.
200+
// The old lock may contain entries tied to the local paths.
201+
const lockPath = join(workDir, 'uv.lock')
202+
if (existsSync(lockPath)) {
203+
unlinkSync(lockPath)
204+
console.log('[test-runner] Removed stale uv.lock (will regenerate)')
205+
}
206+
}
207+
}
208+
138209
/**
139210
* Install dependencies using the detected package manager.
140211
*/
@@ -154,6 +225,12 @@ export function installDependencies(workDir: string, pm: PackageManager): void {
154225
const cmd = commands[pm]
155226
if (!cmd) return
156227

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.
230+
if (pm === 'uv') {
231+
stripLocalUvSources(workDir)
232+
}
233+
157234
console.log(`[test-runner] Installing dependencies with ${pm}: ${cmd}`)
158235
try {
159236
execSync(cmd, { cwd: workDir, stdio: 'pipe', timeout: 300_000, env: getSafeEnv() })

0 commit comments

Comments
 (0)