diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 39495a416..949120588 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -44,6 +44,7 @@ import { getCondaForWorkspace, getCondaPathSetting, getDefaultCondaPrefix, + isCondaEnvWithoutPython, quickCreateConda, refreshCondaEnvs, resolveCondaPath, @@ -510,7 +511,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { // If a global environment is still not set, try using the 'base' if (!this.globalEnv) { const base = this.findEnvironmentByName('base'); - if (base?.version !== 'no-python') { + if (!base || !isCondaEnvWithoutPython(base)) { this.globalEnv = base; } } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 9034a8e03..04e9a80e6 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -755,7 +755,7 @@ function getCondaWithoutPython(name: string, prefix: string, conda: string): Pyt displayPath: prefix, description: prefix, tooltip: l10n.t('Conda environment without Python'), - version: 'no-python', + version: '', sysPrefix: prefix, iconPath: new ThemeIcon('stop'), execInfo: { @@ -765,6 +765,25 @@ function getCondaWithoutPython(name: string, prefix: string, conda: string): Pyt }; } +const PYTHON_EXECUTABLE_NAME = /^(python|pypy)/i; + +/** + * Whether `environment` describes a conda prefix that has no Python interpreter at all. + * + * Such environments come only from {@link getCondaWithoutPython}, whose `execInfo.run` points at the + * conda launcher because there is no interpreter to run; that runner is what classifies them here. + * An empty `version` is deliberately not sufficient on its own: it is the generic "version unknown" + * value, and other producers (`defaultInterpreterPath` resolution, for one) emit it for interpreters + * that have a real executable and run fine. Those must not be sent through the install-Python flow. + */ +export function isCondaEnvWithoutPython(environment: PythonEnvironment): boolean { + if (environment.version !== '') { + return false; + } + const runner = environment.execInfo?.run?.executable ?? ''; + return !PYTHON_EXECUTABLE_NAME.test(path.basename(runner)); +} + async function nativeToPythonEnv( e: NativeEnvInfo, api: PythonEnvironmentApi, @@ -1360,7 +1379,7 @@ export async function checkForNoPythonCondaEnvironment( api: PythonEnvironmentApi, log: LogOutputChannel, ): Promise { - if (environment.version === 'no-python') { + if (isCondaEnvWithoutPython(environment)) { if (environment.sysPrefix === '') { await showErrorMessage(CondaStrings.condaMissingPythonNoFix, { modal: true }); return undefined; diff --git a/src/test/managers/common/utils.getLatest.unit.test.ts b/src/test/managers/common/utils.getLatest.unit.test.ts index aa623829a..d05fdd968 100644 --- a/src/test/managers/common/utils.getLatest.unit.test.ts +++ b/src/test/managers/common/utils.getLatest.unit.test.ts @@ -33,4 +33,13 @@ suite('getLatest', () => { assert.strictEqual(getLatest([older, errored]), older); }); + + test('returns the newest environment even when the first candidate has no version', () => { + const versions = ['', '3.13.13', '3.14.7']; + const environments = versions.map((version, index) => + createMockPythonEnvironment({ envPath: path.join('python', String(index)), version }), + ); + + assert.strictEqual(getLatest(environments), environments[2]); + }); }); diff --git a/src/test/managers/common/utils.sortEnvironments.unit.test.ts b/src/test/managers/common/utils.sortEnvironments.unit.test.ts index 58643f90e..5529f0cb8 100644 --- a/src/test/managers/common/utils.sortEnvironments.unit.test.ts +++ b/src/test/managers/common/utils.sortEnvironments.unit.test.ts @@ -3,6 +3,18 @@ import path from 'node:path'; import { sortEnvironments } from '../../../managers/common/utils'; import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; +function permutations(items: T[]): T[][] { + if (items.length <= 1) { + return [items]; + } + const result: T[][] = []; + items.forEach((item, index) => { + const rest = [...items.slice(0, index), ...items.slice(index + 1)]; + permutations(rest).forEach((p) => result.push([item, ...p])); + }); + return result; +} + suite('sortEnvironments', () => { test('sorts normalized PET versions in descending order', () => { const versions = ['3.9.6.final.0', '3.14.3.final.0', '3.11.9.final.0']; @@ -52,4 +64,35 @@ suite('sortEnvironments', () => { assert.deepStrictEqual(sortEnvironments([errored, usable]), [usable, errored]); }); + + test('places environments without a version after those with one', () => { + const versions = ['', '3.12.0', '3.14.7']; + const environments = versions.map((version, index) => + createMockPythonEnvironment({ envPath: path.join('python', String(index)), version }), + ); + + assert.deepStrictEqual( + sortEnvironments(environments).map((environment) => environment.version), + ['3.14.7', '3.12.0', ''], + ); + }); + + test('sorts the same environments the same way regardless of discovery order', () => { + // Include unknown versions and equivalent PET/compact versions so that name and + // path tie breakers remain consistent with PythonVersion comparison. + const environments = [ + { name: 'base', version: '3.13.13', directory: 'base' }, + { name: 'odd', version: 'unknown', directory: 'odd' }, + { name: 'nopy', version: '', directory: 'nopy' }, + { name: 'lh', version: '3.14.7', directory: 'lh' }, + { name: 'lh', version: '3.14.7.final.0', directory: 'lh-pet' }, + ].map(({ name, version, directory }) => + createMockPythonEnvironment({ name, envPath: path.join('python', directory), version }), + ); + const expected = [environments[3], environments[4], environments[0], environments[2], environments[1]]; + + for (const permutation of permutations(environments)) { + assert.deepStrictEqual(sortEnvironments(permutation), expected); + } + }); }); diff --git a/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts b/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts index bab2831f8..2eaf34cbd 100644 --- a/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts @@ -13,7 +13,10 @@ import { NativePythonFinder } from '../../../managers/common/nativePythonFinder' import { CondaEnvManager } from '../../../managers/conda/condaEnvManager'; import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils'; import * as condaUtils from '../../../managers/conda/condaUtils'; -import { makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment'; +import { + makeMockCondaEnvironment as makeEnv, + makeMockCondaEnvironmentWithoutPython as makeNoPythonEnv, +} from '../../mocks/pythonEnvironment'; /** * Tests for the lazy-registration flow on CondaEnvManager.initialize(). @@ -106,7 +109,7 @@ suite('CondaEnvManager.initialize - lazy registration flow', () => { test('does not use a no-Python base as the implicit global fallback', async () => { getCondaStub.resolves('/usr/bin/conda'); constructSourcingStub.resolves({ toString: () => '' } as any); - const base = makeEnv('base', Uri.file('/opt/miniconda3').fsPath, 'no-python'); + const base = makeNoPythonEnv('base', Uri.file('/opt/miniconda3').fsPath); refreshCondaEnvsStub.resolves([base]); const mgr = createManager(); @@ -131,7 +134,7 @@ suite('CondaEnvManager.initialize - lazy registration flow', () => { getCondaStub.resolves('/usr/bin/conda'); constructSourcingStub.resolves({ toString: () => '' } as any); const basePath = Uri.file('/opt/miniconda3').fsPath; - const base = makeEnv('base', basePath, 'no-python'); + const base = makeNoPythonEnv('base', basePath); refreshCondaEnvsStub.resolves([base]); getCondaForGlobalStub.resolves(basePath); diff --git a/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts b/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts index a75c4fb79..137362ecc 100644 --- a/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts @@ -5,7 +5,10 @@ import { PythonEnvironmentApi } from '../../../api'; import { CondaEnvManager } from '../../../managers/conda/condaEnvManager'; import * as condaUtils from '../../../managers/conda/condaUtils'; import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; -import { makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment'; +import { + makeMockCondaEnvironment as makeEnv, + makeMockCondaEnvironmentWithoutPython as makeNoPythonEnv, +} from '../../mocks/pythonEnvironment'; function createManager(): CondaEnvManager { const manager = new CondaEnvManager( @@ -78,7 +81,7 @@ suite('CondaEnvManager.set - globalEnv update', () => { test('set(undefined, noPythonEnv) where user declines install clears globalEnv', async () => { const manager = createManager(); const oldEnv = makeEnv('base', '/miniconda3', '3.11.0'); - const noPythonEnv = makeEnv('nopy', '/miniconda3/envs/nopy', 'no-python'); + const noPythonEnv = makeNoPythonEnv('nopy', '/miniconda3/envs/nopy'); (manager as any).globalEnv = oldEnv; // User declined to install Python diff --git a/src/test/managers/conda/condaUtils.noPythonEnv.unit.test.ts b/src/test/managers/conda/condaUtils.noPythonEnv.unit.test.ts new file mode 100644 index 000000000..df896a02e --- /dev/null +++ b/src/test/managers/conda/condaUtils.noPythonEnv.unit.test.ts @@ -0,0 +1,124 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, WorkspaceConfiguration } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PythonEnvironmentImpl } from '../../../internal.api'; +import { NativePythonEnvironmentKind, NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { + checkForNoPythonCondaEnvironment, + isCondaEnvWithoutPython, + resolveCondaPath, +} from '../../../managers/conda/condaUtils'; +import { createMockPythonEnvironment, makeMockCondaEnvironmentWithoutPython } from '../../mocks/pythonEnvironment'; + +suite('Conda Utils - environment without Python', () => { + let captured: PythonEnvironmentInfo | undefined; + let api: PythonEnvironmentApi; + let log: LogOutputChannel; + let showErrorMessageStub: sinon.SinonStub; + + setup(() => { + captured = undefined; + + const config = { get: sinon.stub() }; + config.get.withArgs('condaPath').returns('conda'); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python') + .returns(config as unknown as WorkspaceConfiguration); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + + api = { + createPythonEnvironmentItem: (info: PythonEnvironmentInfo) => { + captured = info; + return new PythonEnvironmentImpl( + { id: `${info.name}-test`, managerId: 'ms-python.python:conda' }, + info, + ); + }, + } as unknown as PythonEnvironmentApi; + + log = { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as unknown as LogOutputChannel; + }); + + teardown(() => { + sinon.restore(); + }); + + test('reports an empty version rather than a placeholder that is not a version', async () => { + // A conda prefix used purely as a toolchain (`conda create -n cuda cuda-toolkit`) has + // no interpreter. `version` is part of the public API and consumers parse it as a PEP + // 440 version, so "unknown" has to be the empty string: `ms-python.python` throws on + // any other unparseable value, and the throw takes down the whole batch of + // environments being published, not just this one. + const nativeFinder = { + resolve: sinon.stub().resolves({ + kind: NativePythonEnvironmentKind.conda, + name: 'cuda', + prefix: '/miniconda3/envs/cuda', + }), + } as unknown as NativePythonFinder; + + const result = await resolveCondaPath( + '/miniconda3/envs/cuda', + nativeFinder, + api, + log, + {} as EnvironmentManager, + ); + + assert.ok(result, 'the environment should still be discovered'); + assert.ok(captured, 'createPythonEnvironmentItem should have been called'); + assert.strictEqual(captured.version, ''); + assert.ok(isCondaEnvWithoutPython(result), 'the environment should be recognized as having no Python'); + + // The marker belongs in the display strings, which are shown but never parsed. + assert.ok(captured.displayName?.includes('(no-python)'), 'display name should still mark the environment'); + }); + + test('does not treat an interpreter with an unknown version as missing', async () => { + // `''` is also the generic "version unknown" value: `defaultInterpreterPath` resolution + // produces exactly this shape when PET returns an executable without a version. The + // interpreter is real and runnable, so it must pass through `set()` untouched rather + // than be routed into the install-Python flow. + const environment = createMockPythonEnvironment({ + name: 'defaultInterpreterPath: ', + envPath: '/miniconda3/envs/cuda/bin/python', + sysPrefix: '/miniconda3/envs/cuda', + version: '', + }); + assert.strictEqual(environment.execInfo.run.executable, 'python'); + + assert.strictEqual(isCondaEnvWithoutPython(environment), false); + + const checked = await checkForNoPythonCondaEnvironment( + {} as NativePythonFinder, + {} as EnvironmentManager, + environment, + api, + log, + ); + + assert.strictEqual(checked, environment, 'the environment should be returned as-is'); + assert.ok(showErrorMessageStub.notCalled, 'no missing-Python prompt should be shown'); + }); + + test('still offers to install Python for a prefix that has no interpreter', async () => { + const environment = makeMockCondaEnvironmentWithoutPython('cuda', '/miniconda3/envs/cuda'); + + assert.strictEqual(isCondaEnvWithoutPython(environment), true); + + const checked = await checkForNoPythonCondaEnvironment( + {} as NativePythonFinder, + {} as EnvironmentManager, + environment, + api, + log, + ); + + assert.strictEqual(checked, undefined, 'declining the install should clear the selection'); + assert.ok(showErrorMessageStub.calledOnce, 'the missing-Python prompt should be shown'); + }); +}); diff --git a/src/test/mocks/pythonEnvironment.ts b/src/test/mocks/pythonEnvironment.ts index 78562c4ed..059a13f70 100644 --- a/src/test/mocks/pythonEnvironment.ts +++ b/src/test/mocks/pythonEnvironment.ts @@ -79,3 +79,26 @@ export function createMockPythonEnvironment(options: MockPythonEnvironmentOption export function makeMockCondaEnvironment(name: string, envPath: string, version: string = '3.12.0'): PythonEnvironment { return createMockPythonEnvironment({ name, envPath, version }); } + +/** + * Creates a mock conda environment that has no Python interpreter, shaped like the item + * `getCondaWithoutPython` produces: an empty version and the conda launcher as the runner. + */ +export function makeMockCondaEnvironmentWithoutPython( + name: string, + envPath: string, + conda: string = '/miniconda3/bin/conda', +): PythonEnvironment { + return new PythonEnvironmentImpl( + { id: `${name}-test`, managerId: 'ms-python.python:conda' }, + { + name, + displayName: `${name} (no-python)`, + displayPath: envPath, + version: '', + environmentPath: Uri.file(envPath), + sysPrefix: envPath, + execInfo: { run: { executable: conda } }, + }, + ); +}