Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/managers/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/managers/conda/condaEnvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
getCondaForWorkspace,
getCondaPathSetting,
getDefaultCondaPrefix,
isCondaEnvWithoutPython,
quickCreateConda,
refreshCondaEnvs,
resolveCondaPath,
Expand Down Expand Up @@ -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;
}
}
Expand Down
8 changes: 6 additions & 2 deletions src/managers/conda/condaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -1388,7 +1392,7 @@ export async function checkForNoPythonCondaEnvironment(
api: PythonEnvironmentApi,
log: LogOutputChannel,
): Promise<PythonEnvironment | undefined> {
if (environment.version === 'no-python') {
if (isCondaEnvWithoutPython(environment)) {
if (environment.sysPrefix === '') {
await showErrorMessage(CondaStrings.condaMissingPythonNoFix, { modal: true });
return undefined;
Expand Down
67 changes: 67 additions & 0 deletions src/test/managers/common/utils.sortEnvironments.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/test/managers/conda/condaUtils.noPythonEnv.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});