diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index dc44fe75..26a35ca2 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -185,12 +185,18 @@ export async function refreshPythons( const PIP_LIST_TIMEOUT_MS = 30_000; +function getUvEnvironmentTarget(environment: PythonEnvironment): string { + // A virtual environment's executable may resolve to its base interpreter. uv accepts + // the environment directory directly, which preserves the environment boundary. + return environment.environmentPath.fsPath; +} + async function execPipList(environment: PythonEnvironment, log?: LogOutputChannel, args?: string[]): Promise { // Use environmentPath directly for consistency with UV environment tracking const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); if (useUv) { return await runUV( - ['pip', 'list', '--python', environment.execInfo.run.executable, '--format=json', ...(args ?? [])], + ['pip', 'list', '--python', getUvEnvironmentTarget(environment), '--format=json', ...(args ?? [])], undefined, log, undefined, @@ -258,7 +264,7 @@ export async function refreshPipDirectPackageNames( const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); if (useUv) { const treeOutput = await runUV( - ['pip', 'tree', '--python', environment.execInfo.run.executable, '--depth=0'], + ['pip', 'tree', '--python', getUvEnvironmentTarget(environment), '--depth=0'], undefined, log, undefined, @@ -287,7 +293,7 @@ export async function managePackages( if (options.uninstall && options.uninstall.length > 0) { if (useUv) { await runUV( - [...uninstallArgs, '--python', environment.execInfo.run.executable, ...options.uninstall], + [...uninstallArgs, '--python', getUvEnvironmentTarget(environment), ...options.uninstall], undefined, manager.log, token, @@ -313,7 +319,7 @@ export async function managePackages( if (useUv) { await runUV( - [...installArgs, '--python', environment.execInfo.run.executable, ...processedInstallArgs], + [...installArgs, '--python', getUvEnvironmentTarget(environment), ...processedInstallArgs], undefined, manager.log, token, diff --git a/src/test/managers/builtin/packageManagement.unit.test.ts b/src/test/managers/builtin/packageManagement.unit.test.ts new file mode 100644 index 00000000..8360bcf3 --- /dev/null +++ b/src/test/managers/builtin/packageManagement.unit.test.ts @@ -0,0 +1,101 @@ +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typeMoq from 'typemoq'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PackageManager, PythonEnvironment } from '../../../api'; +import * as helpers from '../../../managers/builtin/helpers'; +import { + managePackages, + refreshPipDirectPackageNames, + refreshPipPackages, +} from '../../../managers/builtin/utils'; +import { createMockLogOutputChannel, setupNonThenable } from '../../mocks/helper'; + +suite('Pip package management', () => { + let environment: typeMoq.IMock; + let manager: typeMoq.IMock; + let log: LogOutputChannel; + let runUvStub: sinon.SinonStub; + let environmentTarget: string; + let baseInterpreter: string; + + setup(() => { + environmentTarget = Uri.file(path.join('test-data', 'pipenv-environment')).fsPath; + baseInterpreter = Uri.file(path.join('test-data', 'uv', 'python')).fsPath; + + environment = typeMoq.Mock.ofType(); + environment.setup((env) => env.version).returns(() => '3.13.0'); + environment.setup((env) => env.environmentPath).returns(() => Uri.file(environmentTarget)); + environment.setup((env) => env.execInfo).returns(() => ({ run: { executable: baseInterpreter } })); + setupNonThenable(environment); + + log = createMockLogOutputChannel(); + manager = typeMoq.Mock.ofType(); + manager.setup((packageManager) => packageManager.log).returns(() => log); + setupNonThenable(manager); + + sinon.stub(helpers, 'shouldUseUv').resolves(true); + runUvStub = sinon.stub(helpers, 'runUV').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + test('uses the environment path when listing packages with uv', async () => { + runUvStub.resolves('[]'); + + const packages = await refreshPipPackages(environment.object, log); + + assert.deepStrictEqual(packages, []); + sinon.assert.calledWithExactly( + runUvStub, + ['pip', 'list', '--python', environmentTarget, '--format=json'], + undefined, + log, + undefined, + 30_000, + ); + assert.ok(!runUvStub.calledWith(sinon.match.array.contains([baseInterpreter]))); + }); + + test('uses the environment path when finding direct packages with uv', async () => { + await refreshPipDirectPackageNames(environment.object, log); + + sinon.assert.calledWithExactly( + runUvStub, + ['pip', 'tree', '--python', environmentTarget, '--depth=0'], + undefined, + log, + undefined, + 30_000, + ); + assert.ok(!runUvStub.calledWith(sinon.match.array.contains([baseInterpreter]))); + }); + + test('uses the environment path when installing and uninstalling packages with uv', async () => { + await managePackages( + environment.object, + { install: ['absl-py'], uninstall: ['setuptools'] }, + manager.object, + ); + + sinon.assert.calledTwice(runUvStub); + sinon.assert.calledWithExactly( + runUvStub.firstCall, + ['pip', 'uninstall', '--python', environmentTarget, 'setuptools'], + undefined, + log, + undefined, + ); + sinon.assert.calledWithExactly( + runUvStub.secondCall, + ['pip', 'install', '--python', environmentTarget, 'absl-py'], + undefined, + log, + undefined, + ); + assert.ok(!runUvStub.calledWith(sinon.match.array.contains([baseInterpreter]))); + }); +});