From 27ef4068b905535f184598c1fd88714c8b06ceeb Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 19 Mar 2026 10:25:05 -0400 Subject: [PATCH 1/2] 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) --- apps/worker/src/__tests__/test-runner.test.ts | 108 +++++++++++++++++- apps/worker/src/test-runner.ts | 79 ++++++++++++- 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/apps/worker/src/__tests__/test-runner.test.ts b/apps/worker/src/__tests__/test-runner.test.ts index 8bd40cd..a30f370 100644 --- a/apps/worker/src/__tests__/test-runner.test.ts +++ b/apps/worker/src/__tests__/test-runner.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' -import { detectTestRunner, detectPackageManager, detectMonorepo, runTests } from '../test-runner.js' +import { existsSync, readFileSync } from 'fs' +import { detectTestRunner, detectPackageManager, detectMonorepo, runTests, stripLocalUvSources } from '../test-runner.js' // Helper: create a temp directory with specific files function createTempDir(): string { @@ -244,6 +245,111 @@ describe('detectMonorepo', () => { }) }) +describe('stripLocalUvSources', () => { + let tempDir: string + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('strips path-based source entries from pyproject.toml', () => { + const pyprojectContent = `[project] +name = "test-project" +dependencies = [ + "openadapt-ml>=0.11.0", + "openadapt-consilium>=0.3.2", +] + +[tool.uv.sources] +openadapt-consilium = { git = "https://github.com/OpenAdaptAI/openadapt-consilium.git" } +openadapt-ml = { path = "../openadapt-ml", editable = true } + +[tool.hatch.build.targets.wheel] +packages = ["my_package"] +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + // Path-based entry should be removed + expect(result).not.toContain('openadapt-ml') + expect(result).not.toContain('path = "../openadapt-ml"') + // Git-based entry should be preserved + expect(result).toContain('openadapt-consilium') + expect(result).toContain('git = "https://github.com/') + // Other sections should be preserved + expect(result).toContain('[project]') + expect(result).toContain('[tool.hatch.build.targets.wheel]') + // uv.lock should be deleted + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(false) + }) + + it('preserves pyproject.toml without [tool.uv.sources]', () => { + const pyprojectContent = `[project] +name = "test-project" +dependencies = ["requests>=2.28.0"] +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).toBe(pyprojectContent) + // uv.lock should NOT be deleted when no changes were made + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true) + }) + + it('preserves pyproject.toml with only git sources', () => { + const pyprojectContent = `[project] +name = "test-project" + +[tool.uv.sources] +consilium = { git = "https://github.com/example/consilium.git" } +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + touchFile(tempDir, 'uv.lock', 'some lock content') + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).toBe(pyprojectContent) + // uv.lock should NOT be deleted when no path sources were stripped + expect(existsSync(join(tempDir, 'uv.lock'))).toBe(true) + }) + + it('handles multiple path-based sources', () => { + const pyprojectContent = `[project] +name = "test-project" + +[tool.uv.sources] +pkg-a = { path = "../pkg-a", editable = true } +pkg-b = { git = "https://github.com/example/pkg-b.git" } +pkg-c = { path = "/absolute/path/to/pkg-c" } +` + touchFile(tempDir, 'pyproject.toml', pyprojectContent) + + stripLocalUvSources(tempDir) + + const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') + expect(result).not.toContain('pkg-a') + expect(result).not.toContain('pkg-c') + expect(result).toContain('pkg-b') + expect(result).toContain('git = "https://github.com/') + }) + + it('does nothing when pyproject.toml does not exist', () => { + // Should not throw + expect(() => stripLocalUvSources(tempDir)).not.toThrow() + }) +}) + describe('runTests with real commands', () => { let tempDir: string diff --git a/apps/worker/src/test-runner.ts b/apps/worker/src/test-runner.ts index 4591503..e65f063 100644 --- a/apps/worker/src/test-runner.ts +++ b/apps/worker/src/test-runner.ts @@ -1,5 +1,5 @@ import { execSync } from 'child_process' -import { existsSync, readFileSync } from 'fs' +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs' import { join } from 'path' import type { TestRunner, PackageManager, TestResults, TestFailure } from '@wright/shared' @@ -135,6 +135,77 @@ export function detectPackageManager(workDir: string): PackageManager { return 'none' } +/** + * Strip local path-based source overrides from pyproject.toml's [tool.uv.sources]. + * + * Many projects use `[tool.uv.sources]` to point dependencies at local sibling + * directories for development (e.g. `openadapt-ml = { path = "../openadapt-ml", editable = true }`). + * These paths don't exist on the worker, causing `uv sync` to fail immediately. + * + * This function removes any source entries that use `path = "..."` (local + * filesystem references) while preserving git/url sources. It also removes the + * stale `uv.lock` so uv regenerates it with PyPI-resolved versions. + * + * The packages themselves are still listed as regular dependencies (e.g. + * `openadapt-ml>=0.11.0`) and will resolve from PyPI without the source override. + */ +export function stripLocalUvSources(workDir: string): void { + const pyprojectPath = join(workDir, 'pyproject.toml') + if (!existsSync(pyprojectPath)) return + + const content = readFileSync(pyprojectPath, 'utf-8') + + // Check if there's a [tool.uv.sources] section with path-based entries + if (!content.includes('[tool.uv.sources]')) return + + const lines = content.split('\n') + const outputLines: string[] = [] + let inUvSources = false + let strippedAny = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + + // Detect start of [tool.uv.sources] section + if (trimmed === '[tool.uv.sources]') { + inUvSources = true + outputLines.push(line) + continue + } + + // Detect start of any other section (ends [tool.uv.sources]) + if (inUvSources && trimmed.startsWith('[') && trimmed.endsWith(']')) { + inUvSources = false + } + + if (inUvSources) { + // Skip lines that contain `path =` (local filesystem source) + // These look like: `package-name = { path = "../some-dir", editable = true }` + if (/\bpath\s*=\s*"/.test(line)) { + strippedAny = true + console.log(`[test-runner] Stripped local uv source: ${trimmed}`) + continue + } + } + + outputLines.push(line) + } + + if (strippedAny) { + writeFileSync(pyprojectPath, outputLines.join('\n')) + console.log('[test-runner] Removed local path sources from pyproject.toml') + + // Delete uv.lock so uv regenerates it without the local sources. + // The old lock may contain entries tied to the local paths. + const lockPath = join(workDir, 'uv.lock') + if (existsSync(lockPath)) { + unlinkSync(lockPath) + console.log('[test-runner] Removed stale uv.lock (will regenerate)') + } + } +} + /** * Install dependencies using the detected package manager. */ @@ -154,6 +225,12 @@ export function installDependencies(workDir: string, pm: PackageManager): void { const cmd = commands[pm] if (!cmd) return + // For uv projects, strip local path sources from pyproject.toml that reference + // sibling directories (e.g. "../openadapt-ml") which won't exist on the worker. + if (pm === 'uv') { + stripLocalUvSources(workDir) + } + console.log(`[test-runner] Installing dependencies with ${pm}: ${cmd}`) try { execSync(cmd, { cwd: workDir, stdio: 'pipe', timeout: 300_000, env: getSafeEnv() }) From 24aa0d131f0d02795216db472768e32f2a598d19 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Thu, 19 Mar 2026 10:37:31 -0400 Subject: [PATCH 2/2] fix: test should check source override removed, not dependency itself Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/worker/src/__tests__/test-runner.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/worker/src/__tests__/test-runner.test.ts b/apps/worker/src/__tests__/test-runner.test.ts index a30f370..3522e70 100644 --- a/apps/worker/src/__tests__/test-runner.test.ts +++ b/apps/worker/src/__tests__/test-runner.test.ts @@ -277,9 +277,10 @@ packages = ["my_package"] stripLocalUvSources(tempDir) const result = readFileSync(join(tempDir, 'pyproject.toml'), 'utf-8') - // Path-based entry should be removed - expect(result).not.toContain('openadapt-ml') + // Path-based source override should be removed expect(result).not.toContain('path = "../openadapt-ml"') + // But the dependency itself in [project.dependencies] should remain + expect(result).toContain('openadapt-ml>=0.11.0') // Git-based entry should be preserved expect(result).toContain('openadapt-consilium') expect(result).toContain('git = "https://github.com/')