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
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));
Comment on lines +783 to +784
}

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 @@ -1360,7 +1379,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
9 changes: 9 additions & 0 deletions src/test/managers/common/utils.getLatest.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
43 changes: 43 additions & 0 deletions src/test/managers/common/utils.sortEnvironments.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import path from 'node:path';
import { sortEnvironments } from '../../../managers/common/utils';
import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment';

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('sorts normalized PET versions in descending order', () => {
const versions = ['3.9.6.final.0', '3.14.3.final.0', '3.11.9.final.0'];
Expand Down Expand Up @@ -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);
}
});
});
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 } },
},
);
}
Loading