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
23 changes: 21 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,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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

version === '' also represents an unknown version generally, so it cannot reliably prove that Python is absent. Preserve a Conda-specific discriminator (or check executable availability), and cover an environment with unavailable version metadata that still has a runnable interpreter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I need to separate "no version" and "no interpreter" into two different flag?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these need to remain two distinct states, but not necessarily as two new public flags. "" is already the generic "version metadata unavailable" value: tryResolveInterpreterPath creates a runnable environment with version: resolved.version ?? "" and a real executable. If that resolves to the Conda manager, this predicate sends an already-runnable interpreter through the install-Python flow and can reject it as the base fallback. Please classify no-interpreter independently—e.g. through the existing error/capability representation—and add a regression test with a valid executable plus an empty version.


async function nativeToPythonEnv(
e: NativeEnvInfo,
api: PythonEnvironmentApi,
Expand Down Expand Up @@ -1365,7 +1384,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 @@ -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().
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 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,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');
});
});
23 changes: 23 additions & 0 deletions src/test/mocks/pythonEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
},
);
}