diff --git a/src/managers/builtin/commands/factory.ts b/src/managers/builtin/commands/factory.ts index 6ff6980d..947d1de6 100644 --- a/src/managers/builtin/commands/factory.ts +++ b/src/managers/builtin/commands/factory.ts @@ -11,9 +11,14 @@ export async function createPipOrUvCommandWithKind( PipCommand: CommandConstructor

, UvCommand: CommandConstructor, ): Promise> { - return (await shouldUseUv(options.log, environmentPath)) - ? { kind: 'uv', command: new UvCommand(options) } - : { kind: 'pip', command: new PipCommand(options) }; + if (await shouldUseUv(options.log, environmentPath)) { + // uv accepts an environment directory as its `--python` target. A symlinked + // environment executable (for example Pipenv) can resolve to the externally + // managed base interpreter, so passing the environment directory preserves + // the environment boundary. Pip commands keep using the interpreter itself. + return { kind: 'uv', command: new UvCommand({ ...options, pythonExecutable: environmentPath }) }; + } + return { kind: 'pip', command: new PipCommand(options) }; } export async function createPipOrUvCommand( diff --git a/src/test/managers/builtin/commands.unit.test.ts b/src/test/managers/builtin/commands.unit.test.ts index d9d26c5c..6619c06a 100644 --- a/src/test/managers/builtin/commands.unit.test.ts +++ b/src/test/managers/builtin/commands.unit.test.ts @@ -13,9 +13,13 @@ import { PipAvailableVersionsTextCommand, UvAvailableVersionsCommand, } from '../../../managers/builtin/commands/availableVersions'; +import { createPipOrUvCommand } from '../../../managers/builtin/commands/factory'; import { PipInstallCommand, UvInstallCommand } from '../../../managers/builtin/commands/install'; import { PipListCommand, UvListCommand } from '../../../managers/builtin/commands/list'; -import { PipListDirectNamesCommand, UvListDirectNamesCommand } from '../../../managers/builtin/commands/listDirectNames'; +import { + PipListDirectNamesCommand, + UvListDirectNamesCommand, +} from '../../../managers/builtin/commands/listDirectNames'; import { PipUninstallCommand, UvUninstallCommand } from '../../../managers/builtin/commands/uninstall'; import { PipVersionCommand, UvVersionCommand } from '../../../managers/builtin/commands/version'; import * as helpers from '../../../managers/builtin/helpers'; @@ -28,6 +32,7 @@ suite('Pip and UV command parsing', () => { let mockLog: LogOutputChannel; let runPythonStub: sinon.SinonStub; let runUvStub: sinon.SinonStub; + let shouldUseUvStub: sinon.SinonStub; setup(() => { log = createMockLogOutputChannel(); @@ -37,6 +42,7 @@ suite('Pip and UV command parsing', () => { } as unknown as ReturnType); runPythonStub = sinon.stub(helpers, 'runPython').resolves(''); runUvStub = sinon.stub(helpers, 'runUV').resolves(''); + shouldUseUvStub = sinon.stub(helpers, 'shouldUseUv').resolves(false); }); teardown(() => { @@ -259,7 +265,10 @@ suite('Pip and UV command parsing', () => { '--python-version', '3.13.1', ]); - assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']); + assert.deepStrictEqual( + result.map((version) => version.public), + ['1.0.0'], + ); }); test('PipAvailableVersionsTextCommand parses text output for Pip 21.2 through 25.0', async () => { @@ -282,7 +291,10 @@ suite('Pip and UV command parsing', () => { '--python-version', '3.13.1', ]); - assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']); + assert.deepStrictEqual( + result.map((version) => version.public), + ['1.0.0'], + ); }); test('UvAvailableVersionsCommand rejects output surrounding JSON', async () => { @@ -319,7 +331,10 @@ suite('Pip and UV command parsing', () => { const result = await command.execute(); assert.deepStrictEqual(runUvStub.firstCall.args[0], ['pip', 'list', '--format=json', '--python', 'python']); - assert.deepStrictEqual(result.map((pkg) => pkg.name), ['package']); + assert.deepStrictEqual( + result.map((pkg) => pkg.name), + ['package'], + ); }); test('direct-package commands normalize names and ignore UV dependencies', async () => { @@ -349,4 +364,72 @@ suite('Pip and UV command parsing', () => { assert.strictEqual(pipVersion?.public, '24.0'); assert.strictEqual(uvVersion?.public, '0.4.20'); }); + + test('UV package commands target the environment directory rather than the resolved interpreter', async () => { + const baseInterpreter = path.join(path.sep, 'uv', 'python', 'cpython-3.13', 'bin', 'python'); + const environmentPath = path.join(path.sep, 'virtualenvs', 'pipenv-project'); + shouldUseUvStub.resolves(true); + const options = { pythonExecutable: baseInterpreter, log: mockLog }; + + const install: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand( + options, + environmentPath, + PipInstallCommand, + UvInstallCommand, + ); + await install.execute({ packages: [{ packageName: 'requests' }] }); + + const uninstall: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand( + options, + environmentPath, + PipUninstallCommand, + UvUninstallCommand, + ); + await uninstall.execute({ packages: [{ packageName: 'requests' }] }); + + const list: PipListCommand | UvListCommand = await createPipOrUvCommand( + options, + environmentPath, + PipListCommand, + UvListCommand, + ); + runUvStub.resolves('[]'); + await list.execute(); + + const directNames: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand( + options, + environmentPath, + PipListDirectNamesCommand, + UvListDirectNamesCommand, + ); + runUvStub.resolves(''); + await directNames.execute(); + + assert.strictEqual(runPythonStub.callCount, 0); + assert.strictEqual(runUvStub.callCount, 4); + for (const call of runUvStub.getCalls()) { + const args = call.args[0] as string[]; + const pythonIndex = args.indexOf('--python'); + assert.notStrictEqual(pythonIndex, -1); + assert.strictEqual(args[pythonIndex + 1], environmentPath); + assert.ok(!args.includes(baseInterpreter)); + } + }); + + test('Pip package commands continue using the environment interpreter', async () => { + const pythonExecutable = path.join(path.sep, 'virtualenvs', 'project', 'bin', 'python'); + const environmentPath = path.join(path.sep, 'virtualenvs', 'project'); + shouldUseUvStub.resolves(false); + + const install: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand( + { pythonExecutable, log: mockLog }, + environmentPath, + PipInstallCommand, + UvInstallCommand, + ); + await install.execute({ packages: [{ packageName: 'requests' }] }); + + assert.strictEqual(runPythonStub.firstCall.args[0], pythonExecutable); + assert.ok(runUvStub.notCalled); + }); });