diff --git a/src/client/interpreter/activation/service.ts b/src/client/interpreter/activation/service.ts index f47575cad60b..c35d911404ce 100644 --- a/src/client/interpreter/activation/service.ts +++ b/src/client/interpreter/activation/service.ts @@ -253,7 +253,11 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi // Using environment prefix isn't needed as the marker script already takes care of it. command = [...pythonArgv, ...args].map((arg) => arg.toCommandArgumentForPythonExt()).join(' '); } - } else if (interpreter?.envType === EnvironmentType.Pixi) { + } else if ( + interpreter && + [EnvironmentType.Pixi, EnvironmentType.System, EnvironmentType.Unknown].includes(interpreter.envType) + ) { + // Pixi can be resolved by the Environments extension's System fallback. const pythonArgv = await getRunPixiPythonCommand(interpreter.path); if (pythonArgv) { command = [...pythonArgv, ...args].map((arg) => arg.toCommandArgumentForPythonExt()).join(' '); diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index 6443e64f9ae8..cb393ac01bfd 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import { readJSON } from 'fs-extra'; import which from 'which'; import { getUserHomeDir, isWindows } from '../../../common/utils/platform'; -import { exec, getPythonSetting, onDidChangePythonSetting, pathExists } from '../externalDependencies'; +import { arePathsSame, exec, getPythonSetting, onDidChangePythonSetting, pathExists } from '../externalDependencies'; import { cache } from '../../../common/utils/decorators'; import { traceVerbose, traceWarn } from '../../../logging'; import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; @@ -267,15 +267,13 @@ export async function getPixiEnvironmentFromInterpreter( // Otherwise, we'll have to try to deduce this information. // Usually the pixi environments are stored under `/.pixi/envs//`. So, - // we walk backwards to determine the project directory. - let envName: string | undefined; + // we walk backwards to determine a candidate project directory. let envsDir: string; let dotPixiDir: string; let pixiProjectDir: string; let pixiInfo: PixiInfo | undefined; try { - envName = path.basename(prefix); envsDir = path.dirname(prefix); dotPixiDir = path.dirname(envsDir); pixiProjectDir = path.dirname(dotPixiDir); @@ -292,12 +290,18 @@ export async function getPixiEnvironmentFromInterpreter( return undefined; } + const environment = pixiInfo.environments_info.find((env) => arePathsSame(env.prefix, prefix)); + if (!environment) { + traceVerbose(`could not find a pixi environment matching the interpreter at ${interpreterPath}`); + return undefined; + } + return { interpreterPath, pixi, pixiVersion: pixiInfo.version, manifestPath: pixiInfo.project_info.manifest_path, - envName, + envName: environment.name, }; } catch (error) { traceWarn('Error processing paths or getting Pixi Info:', error); diff --git a/src/test/interpreters/activation/service.unit.test.ts b/src/test/interpreters/activation/service.unit.test.ts index a0f9b3bd6915..7bcbea79fcb2 100644 --- a/src/test/interpreters/activation/service.unit.test.ts +++ b/src/test/interpreters/activation/service.unit.test.ts @@ -3,9 +3,11 @@ 'use strict'; import { expect } from 'chai'; +import * as assert from 'assert'; import { EOL } from 'os'; import * as path from 'path'; import { SemVer } from 'semver'; +import * as sinon from 'sinon'; import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; import { EventEmitter, Uri } from 'vscode'; import { IWorkspaceService } from '../../../client/common/application/types'; @@ -29,6 +31,10 @@ import { IInterpreterService } from '../../../client/interpreter/contracts'; import { InterpreterService } from '../../../client/interpreter/interpreterService'; import { EnvironmentType, PythonEnvironment } from '../../../client/pythonEnvironments/info'; import { getSearchPathEnvVarNames } from '../../../client/common/utils/exec'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import * as externalDependencies from '../../../client/pythonEnvironments/common/externalDependencies'; +import { Conda } from '../../../client/pythonEnvironments/common/environmentManagers/conda'; +import * as pixi from '../../../client/pythonEnvironments/common/environmentManagers/pixi'; const getEnvironmentPrefix = 'e8b39361-0157-4923-80e1-22d70d46dee6'; const defaultShells = { @@ -50,6 +56,7 @@ suite('Interpreters Activation - Python Environment Variables', () => { let interpreterService: IInterpreterService; let onDidChangeEnvVariables: EventEmitter; let onDidChangeInterpreter: EventEmitter; + let getRunPixiPythonCommand: sinon.SinonStub; const pythonInterpreter: PythonEnvironment = { path: '/foo/bar/python.exe', version: new SemVer('3.6.6-final'), @@ -59,6 +66,14 @@ suite('Interpreters Activation - Python Environment Variables', () => { architecture: Architecture.x64, }; + setup(() => { + getRunPixiPythonCommand = sinon.stub(pixi, 'getRunPixiPythonCommand').resolves(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + function initSetup(interpreter: PythonEnvironment | undefined) { helper = mock(TerminalHelper); platform = mock(PlatformService); @@ -88,6 +103,161 @@ suite('Interpreters Activation - Python Environment Variables', () => { return `${resource ? 'With a resource' : 'Without a resource'}${interpreter ? ' and an interpreter' : ''}`; } + suite('Pixi environment variable collection', () => { + const projectPath = path.join(EXTENSION_ROOT_DIR, 'project with spaces'); + const manifestPath = path.join(projectPath, 'pixi.toml'); + const pixiExecutable = path.join(EXTENSION_ROOT_DIR, 'pixi tools', 'pixi.exe'); + const resource = Uri.file(path.join(EXTENSION_ROOT_DIR, 'other workspace', 'notebook.ipynb')); + const envTypes = [EnvironmentType.Pixi, EnvironmentType.System, EnvironmentType.Unknown]; + + setup(() => { + initSetup(undefined); + when(platform.osType).thenReturn(OSType.Windows); + when(processServiceFactory.create(resource)).thenResolve(instance(processService)); + when(envVarsService.getEnvironmentVariables(resource)).thenResolve({}); + when(currentProcess.env).thenReturn({ INHERITED: 'preserved' }); + }); + + for (const envType of envTypes) { + for (const envName of ['default', 'analysis']) { + for (const osType of [OSType.Windows, OSType.Linux, OSType.OSX]) { + test(`${envType}, ${envName}, ${osType}: runs Pixi without interactive activation`, async () => { + const prefix = path.join(projectPath, '.pixi', 'envs', envName); + const interpreter = { + ...pythonInterpreter, + path: + osType === OSType.Windows + ? path.join(prefix, 'python.exe') + : path.join(prefix, 'bin', 'python'), + envType, + }; + const vars = { + CONDA_PREFIX: prefix, + PIXI_ENVIRONMENT_NAME: envName, + PYTHONWARNINGS: 'ignore', + }; + when(platform.osType).thenReturn(osType); + when(processService.shellExec(anything(), anything())).thenResolve({ + stdout: JSON.stringify(vars), + }); + sinon.stub(externalDependencies, 'getPythonSetting').returns(pixiExecutable); + sinon + .stub(externalDependencies, 'pathExists') + .callsFake(async (candidate) => candidate === pixiExecutable); + sinon.stub(workspaceApis, 'getWorkspaceFolderPaths').returns([path.dirname(resource.fsPath)]); + const metadata = sinon.stub(pixi.Pixi.prototype, 'getPixiEnvironmentMetadata').resolves({ + manifest_path: manifestPath, + pixi_version: '0.76.1', + environment_name: envName, + }); + getRunPixiPythonCommand.resetBehavior(); + getRunPixiPythonCommand.callThrough(); + + const env = await service.getActivatedEnvironmentVariables(resource, interpreter); + + expect(env).to.deep.equal({ CONDA_PREFIX: prefix, PIXI_ENVIRONMENT_NAME: envName }); + sinon.assert.calledOnceWithExactly(getRunPixiPythonCommand, interpreter.path); + sinon.assert.calledOnceWithExactly(metadata, prefix); + verify( + helper.getEnvironmentActivationShellCommands(anything(), anything(), anything()), + ).never(); + const [command, options] = capture(processService.shellExec).first(); + const expected = [ + pixiExecutable, + 'run', + '--manifest-path', + manifestPath, + ...(envName === 'default' ? [] : ['--environment', envName]), + 'python', + path + .join(EXTENSION_ROOT_DIR, 'python_files', 'printEnvVariables.py') + .fileToCommandArgumentForPythonExt(), + ] + .map((arg) => arg.toCommandArgumentForPythonExt()) + .join(' '); + expect(command).to.equal(expected); + expect(options).to.deep.equal({ + env: { INHERITED: 'preserved', PYTHONWARNINGS: 'ignore' }, + shell: defaultShells[osType], + timeout: 30000, + maxBuffer: 1000 * 1000, + throwOnStdErr: false, + }); + expect(interpreter.envType).to.equal(envType); + }); + } + } + + test(`${envType}: preserves fallback when no Pixi run command is available`, async () => { + const interpreter = { ...pythonInterpreter, envType }; + when(helper.getEnvironmentActivationShellCommands(resource, anything(), interpreter)).thenResolve([ + 'activate-other', + ]); + when(processService.shellExec(anything(), anything())).thenResolve({ stdout: '{"RESULT":"ok"}' }); + + const env = await service.getActivatedEnvironmentVariables(resource, interpreter); + + expect(env).to.deep.equal({ RESULT: 'ok' }); + sinon.assert.calledOnceWithExactly(getRunPixiPythonCommand, interpreter.path); + verify(helper.getEnvironmentActivationShellCommands(resource, anything(), interpreter)).once(); + expect(capture(processService.shellExec).first()[0]).to.contain('activate-other &&'); + }); + } + + for (const envType of [ + EnvironmentType.Venv, + EnvironmentType.Pyenv, + EnvironmentType.Pipenv, + EnvironmentType.Poetry, + ]) { + test(`${envType}: does not add Pixi detection to existing activation`, async () => { + const interpreter = { ...pythonInterpreter, envType }; + when(helper.getEnvironmentActivationShellCommands(resource, anything(), interpreter)).thenResolve([ + 'activate-other', + ]); + when(processService.shellExec(anything(), anything())).thenResolve({ stdout: '{"RESULT":"ok"}' }); + + expect(await service.getActivatedEnvironmentVariables(resource, interpreter)).to.deep.equal({ + RESULT: 'ok', + }); + sinon.assert.notCalled(getRunPixiPythonCommand); + verify(helper.getEnvironmentActivationShellCommands(resource, anything(), interpreter)).once(); + }); + } + + test('preserves conda run and its timeout', async () => { + const interpreter = { ...pythonInterpreter, envType: EnvironmentType.Conda }; + const conda = sinon.createStubInstance(Conda); + conda.getRunPythonArgs.resolves(['conda', 'run', 'python']); + sinon.stub(Conda, 'getConda').resolves(conda); + when(processService.shellExec(anything(), anything())).thenResolve({ stdout: '{"RESULT":"ok"}' }); + + expect(await service.getActivatedEnvironmentVariables(resource, interpreter)).to.deep.equal({ + RESULT: 'ok', + }); + sinon.assert.notCalled(getRunPixiPythonCommand); + verify(helper.getEnvironmentActivationShellCommands(anything(), anything(), anything())).never(); + const [command, options] = capture(processService.shellExec).first(); + expect(command).to.contain('conda run python '); + expect(options?.timeout).to.equal(60000); + }); + + test('does not probe Pixi without an interpreter', async () => { + await service.getActivatedEnvironmentVariables(resource); + sinon.assert.notCalled(getRunPixiPythonCommand); + }); + + test('preserves requested exceptions when pixi run fails', async () => { + const interpreter = { ...pythonInterpreter, envType: EnvironmentType.System }; + getRunPixiPythonCommand.resolves(['pixi', 'run', 'python']); + const error = new Error('Pixi launch failed'); + when(processService.shellExec(anything(), anything())).thenReject(error); + + await assert.rejects(service.getActivatedEnvironmentVariables(resource, interpreter, true), error); + verify(helper.getEnvironmentActivationShellCommands(anything(), anything(), anything())).never(); + }); + }); + [undefined, Uri.parse('a')].forEach((resource) => [undefined, pythonInterpreter].forEach((interpreter) => { suite(title(resource, interpreter), () => { diff --git a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts index 0cbc6b25145c..7eb6609be6ae 100644 --- a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts @@ -2,9 +2,18 @@ import { expect } from 'chai'; import * as path from 'path'; import * as sinon from 'sinon'; import { ExecutionResult, ShellOptions } from '../../../../client/common/process/types'; +import * as platformApis from '../../../../client/common/utils/platform'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; import * as externalDependencies from '../../../../client/pythonEnvironments/common/externalDependencies'; import { TEST_LAYOUT_ROOT } from '../commonTestConstants'; -import { getPixi } from '../../../../client/pythonEnvironments/common/environmentManagers/pixi'; +import { + getPixi, + getPixiActivationCommands, + getPixiEnvironmentFromInterpreter, + getRunPixiPythonCommand, + Pixi, + PixiInfo, +} from '../../../../client/pythonEnvironments/common/environmentManagers/pixi'; export type PixiCommand = { cmd: 'info --json' } | { cmd: '--version' } | { cmd: null }; @@ -145,3 +154,151 @@ suite('Pixi binary is located correctly', async () => { expect(pixi?.command).to.equal(undefined); }); }); + +suite('Pixi interpreter resolution', () => { + const projectPath = path.join(textPixiDir, 'project with spaces'); + const manifestPath = path.join(projectPath, 'pixi.toml'); + const pixiPath = path.join(textPixiDir, 'pixi tools', 'pixi.exe'); + let info: PixiInfo; + let metadata: sinon.SinonStub; + let exec: sinon.SinonStub; + + setup(() => { + info = { + platform: 'win-64', + virtual_packages: [], + version: '0.24.1', + cache_dir: '', + auth_dir: '', + project_info: { + manifest_path: manifestPath, + last_updated: '', + version: '1.0.0', + }, + environments_info: ['default', 'analysis'].map((name) => ({ + name, + prefix: path.join(projectPath, '.pixi', 'envs', name), + features: [], + solve_group: name, + environment_size: 0, + dependencies: [], + tasks: [], + channels: [], + })), + }; + sinon.stub(externalDependencies, 'getPythonSetting').returns(pixiPath); + sinon.stub(externalDependencies, 'pathExists').callsFake(async (candidate) => candidate === pixiPath); + sinon.stub(workspaceApis, 'getWorkspaceFolderPaths').returns([projectPath]); + metadata = sinon.stub(Pixi.prototype, 'getPixiEnvironmentMetadata').resolves(undefined); + exec = sinon.stub(externalDependencies, 'exec').callsFake(async () => ({ stdout: JSON.stringify(info) })); + }); + + teardown(() => { + sinon.restore(); + }); + + for (const envName of ['default', 'analysis']) { + for (const executable of ['python.exe', path.join('bin', 'python')]) { + test(`Resolves a reported ${envName} prefix with ${executable} and no metadata`, async () => { + const prefix = path.join(projectPath, '.pixi', 'envs', envName); + const interpreterPath = path.join(prefix, executable); + + expect(await getPixiEnvironmentFromInterpreter(interpreterPath)).to.deep.include({ + interpreterPath, + manifestPath, + pixiVersion: info.version, + envName, + }); + sinon.assert.calledOnceWithExactly(metadata, prefix); + sinon.assert.calledOnceWithExactly(exec, pixiPath, ['info', '--json'], { + cwd: projectPath, + throwOnStdErr: false, + }); + expect(await getRunPixiPythonCommand(interpreterPath)).to.deep.equal([ + pixiPath.toCommandArgumentForPythonExt(), + 'run', + '--manifest-path', + manifestPath.toCommandArgumentForPythonExt(), + ...(envName === 'default' ? [] : ['--environment', envName]), + 'python', + ]); + }); + } + } + + test('Uses the reported environment name rather than the prefix directory name', async () => { + const environment = info.environments_info[1]; + environment.prefix = path.join(projectPath, '.pixi', 'envs', 'custom-prefix'); + + const result = await getPixiEnvironmentFromInterpreter(path.join(environment.prefix, 'python.exe')); + + expect(result?.envName).to.equal('analysis'); + }); + + for (const prefix of [ + path.join(projectPath, 'tools', 'python', 'default'), + path.join(projectPath, '.pixi', 'envs', 'default-other'), + ]) { + test(`Rejects an unreported prefix: ${path.relative(projectPath, prefix)}`, async () => { + const interpreterPath = path.join(prefix, 'python.exe'); + + expect(await getPixiEnvironmentFromInterpreter(interpreterPath)).to.equal(undefined); + expect(await getRunPixiPythonCommand(interpreterPath)).to.equal(undefined); + expect(await getPixiActivationCommands(interpreterPath)).to.equal(undefined); + }); + } + + test('Rejects a project that reports no environments', async () => { + const interpreterPath = path.join(info.environments_info[0].prefix, 'python.exe'); + info.environments_info = []; + + expect(await getPixiEnvironmentFromInterpreter(interpreterPath)).to.equal(undefined); + }); + + test('Does not use a Pixi workspace for an unrelated system interpreter', async () => { + const interpreterPath = path.join(TEST_LAYOUT_ROOT, 'system-python', 'python.exe'); + + expect(await getPixiEnvironmentFromInterpreter(interpreterPath)).to.equal(undefined); + sinon.assert.notCalled(exec); + }); + + test('Normalizes reported prefixes before comparing them', async () => { + const environment = info.environments_info[0]; + const interpreterPath = path.join(environment.prefix, 'python.exe'); + environment.prefix = `${environment.prefix}${path.sep}unused${path.sep}..`; + + expect((await getPixiEnvironmentFromInterpreter(interpreterPath))?.envName).to.equal('default'); + }); + + for (const osType of [platformApis.OSType.Windows, platformApis.OSType.Linux, platformApis.OSType.OSX]) { + test(`Respects prefix case sensitivity on ${osType}`, async () => { + sinon.stub(platformApis, 'getOSType').returns(osType); + const environment = info.environments_info[0]; + const interpreterPath = path.join(environment.prefix, 'python.exe'); + environment.prefix = environment.prefix.toUpperCase(); + + const result = await getPixiEnvironmentFromInterpreter(interpreterPath); + + expect(result?.envName).to.equal(osType === platformApis.OSType.Windows ? 'default' : undefined); + }); + } + + test('Preserves metadata-based resolution outside the workspace', async () => { + const prefix = path.join(TEST_LAYOUT_ROOT, 'external-prefix'); + const interpreterPath = path.join(prefix, 'python.exe'); + metadata.resolves({ + manifest_path: manifestPath, + pixi_version: info.version, + environment_name: 'analysis', + }); + + expect(await getPixiEnvironmentFromInterpreter(interpreterPath)).to.deep.include({ + interpreterPath, + manifestPath, + pixiVersion: info.version, + envName: 'analysis', + }); + sinon.assert.calledOnceWithExactly(metadata, prefix); + sinon.assert.notCalled(exec); + }); +});