diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index ba7390c4d..edcf683ee 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -46,10 +46,15 @@ export function sortEnvironments(collection: PythonEnvironment[]): PythonEnviron return -1; } if (a.version !== b.version) { - if (pep440Valid(a.version) && pep440Valid(b.version)) { + const aValid = pep440Valid(a.version); + const bValid = pep440Valid(b.version); + if (aValid && bValid) { return pep440Compare(b.version, a.version); // descending } - return a.version ? 1 : -1; + if (aValid !== bValid) { + return aValid ? -1 : 1; // known versions before unknown ones + } + return a.version.localeCompare(b.version); } const value = a.name.localeCompare(b.name); if (value !== 0) { @@ -69,7 +74,10 @@ export function getLatest(collection: PythonEnvironment[]): PythonEnvironment | let latest = candidates[0]; for (const env of candidates) { - if (pep440Valid(env.version) && pep440Valid(latest.version) && pep440Compare(env.version, latest.version) > 0) { + if (!pep440Valid(env.version)) { + continue; + } + if (!pep440Valid(latest.version) || pep440Compare(env.version, latest.version) > 0) { latest = env; } } 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 eee067ce3..6437ee276 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, @@ -1365,7 +1384,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.sortEnvironments.unit.test.ts b/src/test/managers/common/utils.sortEnvironments.unit.test.ts new file mode 100644 index 000000000..13b39ad13 --- /dev/null +++ b/src/test/managers/common/utils.sortEnvironments.unit.test.ts @@ -0,0 +1,67 @@ +import assert from 'assert'; +import { PythonEnvironment } from '../../../api'; +import { getLatest, sortEnvironments } from '../../../managers/common/utils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +function env(name: string, version: string): PythonEnvironment { + return createMockPythonEnvironment({ name, envPath: `/envs/${name}`, version }); +} + +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('orders environments with a known version descending', () => { + const sorted = sortEnvironments([env('a', '3.12.0'), env('b', '3.14.7'), env('c', '3.13.13')]); + + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['b', 'c', 'a'], + ); + }); + + test('places environments without a version after those with one', () => { + const sorted = sortEnvironments([env('nopy', ''), env('a', '3.12.0'), env('b', '3.14.7')]); + + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['b', 'a', 'nopy'], + ); + }); + + test('sorts the same environments the same way regardless of discovery order', () => { + // `version` is a plain string on the public API, so a manager can surface a value that + // is neither empty nor parseable as PEP 440. Comparing such a value against a real + // version has to stay antisymmetric: otherwise `Array.prototype.sort` is free to + // return an implementation-defined permutation, and the list shuffles depending on the + // order the environments happened to be discovered in. + const envs = [env('base', '3.13.13'), env('odd', 'unknown'), env('git', '3.14.6'), env('lh', '3.14.7')]; + + const orders = new Set( + permutations(envs).map((p) => + sortEnvironments([...p]) + .map((e) => e.name) + .join(','), + ), + ); + + assert.strictEqual(orders.size, 1, `expected one stable order, got: ${[...orders].join(' | ')}`); + }); +}); + +suite('getLatest', () => { + test('returns the newest environment even when the first candidate has no version', () => { + const latest = getLatest([env('nopy', ''), env('base', '3.13.13'), env('lh', '3.14.7')]); + + assert.strictEqual(latest?.name, 'lh'); + }); +}); 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 } }, + }, + ); +}