diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index ba7390c4..edcf683e 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 39495a41..94912058 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 06d3ec54..50826067 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,10 @@ function getCondaWithoutPython(name: string, prefix: string, conda: string): Pyt }; } +export function isCondaEnvWithoutPython(environment: PythonEnvironment): boolean { + return environment.version === ''; +} + async function nativeToPythonEnv( e: NativeEnvInfo, api: PythonEnvironmentApi, @@ -1388,7 +1392,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 00000000..13b39ad1 --- /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 bab2831f..a9c12ed0 100644 --- a/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts @@ -106,7 +106,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 = makeEnv('base', Uri.file('/opt/miniconda3').fsPath, ''); refreshCondaEnvsStub.resolves([base]); const mgr = createManager(); @@ -131,7 +131,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 = makeEnv('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 a75c4fb7..3714e8bd 100644 --- a/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.setGlobal.unit.test.ts @@ -78,7 +78,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 = makeEnv('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 00000000..bf98b94e --- /dev/null +++ b/src/test/managers/conda/condaUtils.noPythonEnv.unit.test.ts @@ -0,0 +1,73 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, WorkspaceConfiguration } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { NativePythonEnvironmentKind, NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { isCondaEnvWithoutPython, resolveCondaPath } from '../../../managers/conda/condaUtils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('Conda Utils - environment without Python', () => { + let captured: PythonEnvironmentInfo | undefined; + let api: PythonEnvironmentApi; + let log: LogOutputChannel; + + 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); + + api = { + createPythonEnvironmentItem: (info: PythonEnvironmentInfo) => { + captured = info; + return createMockPythonEnvironment({ + name: info.name, + envPath: info.displayPath, + version: info.version, + }); + }, + } 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'); + }); +});