Skip to content

Commit e0f1d3c

Browse files
Handle Conda environments without Python in EnvExt adapter (#26112)
## Summary - skip Conda environment placeholders that do not contain a Python interpreter - isolate EnvExt conversion failures per item so one unsupported environment cannot discard later valid environments in the same update batch - harden active-interpreter conversion and use consistent executable identity when removing environments from the compatibility collection - add focused coverage for discovery, active-environment, removal, and refresh flows Fixes microsoft/vscode-python-environments#1681 Related to microsoft/vscode-python-environments#1671 ## Context The Python Environments extension can discover and manage Conda environments that do not currently have Python installed. It intentionally exposes those entries to its Environment Manager UI as placeholders: ```text name: empty-env version: no-python run executable: conda ``` That is useful in the management UI, but the entry is not a selectable Python interpreter. Users reported that PET and Environment Manager showed every Conda environment while both `Python: Select Interpreter` and Jupyter's Python-environment kernel picker showed only System Python and the current project environment. Both users confirmed that Python was delegating discovery to the Python Environments extension, ruling out the legacy discovery path. One user then performed a reversible A/B test: 1. with a no-Python Conda environment under `miniconda3/envs`, Python and Jupyter omitted the other valid Conda environments 2. moving that unchanged directory outside `envs` and reloading restored both pickers 3. moving it back and reloading reproduced the failure ## Root cause `src/client/envExt/envExtApi.ts` translates Python Environments API objects into the legacy `PythonEnvInfo` format consumed by Python and Jupyter. The adapter previously parsed every version unconditionally: ```ts parseVersion(pythonEnv.version) ``` `parseVersion('no-python')` throws `invalid version no-python`. Environment-list changes arrive as a batch and were processed by an unguarded `forEach`. When the no-Python placeholder appeared before valid Conda environments, its conversion exception aborted the callback, so every valid item later in that batch was lost: ```text no-python placeholder -> throws valid Conda A -> never processed valid Conda B -> never processed ``` System environments arrive through a separate valid batch, and individually resolved/current environments can use separate paths, explaining the restricted list users observed. Refresh did not self-heal because each Conda refresh sent another batch containing the same placeholder and failed at the same conversion point. ## Fix ### Safe version conversion A shared EnvExt conversion helper now: - treats the expected `no-python` sentinel as a non-interpreter and skips it at debug log level - catches and logs unexpected malformed version strings instead of throwing into callers Both discovery-list conversion and active-interpreter compatibility conversion use this boundary. ### Per-item fault isolation Every environment-list change is processed independently. A malformed runtime item is logged and skipped without preventing later valid items from reaching Python/Jupyter. Active-environment events are emitted only when every supplied side can be converted, avoiding synthetic partial clear/set transitions while preserving normal valid set, clear, and change behavior. ### Consistent removal identity Adds are keyed by the Python executable path. Removals now derive the same executable identity from the source environment. Previously Conda removals used the environment prefix, so they generally could not remove an entry stored by interpreter executable. ## Behavior and compatibility | Scenario | Result | | --- | --- | | Conda environment without Python | remains visible/manageable in Environment Manager; omitted from Python/Jupyter interpreter lists | | Valid Conda, venv, system, Poetry, Pipenv, Pyenv environments | same conversion and picker behavior as before | | No-Python item before valid items in a batch | placeholder skipped; all valid later items retained | | Unexpected malformed provider item | logged and isolated; later items continue | | Valid active environment set/change/clear | unchanged | | Invalid active environment | ignored instead of throwing | | Conda refresh remove/add batch | old executable entries removed and valid refreshed entries restored | The valid hot path adds only a helper call and the existing version parse. There are no new filesystem operations, environment discovery calls, or background tasks. ## Tests New adapter coverage includes: - `[no-python, valid, valid]` batch handling - malformed version in the middle of a batch - structurally malformed event followed by a valid item - no-Python and partially invalid active-environment events - valid active set/change/clear events - Conda removal by executable identity - refresh batches containing removals, a no-Python placeholder, and valid additions - the actual `triggerRefresh()` path - active-interpreter resolution for no-Python and malformed versions Validation performed: - `gulp compile` - full repository ESLint - focused EnvExt tests: **14 passing** - 24 downstream EnvExt/interpreter-selector/interpreter/environment-API test files: **345 passing, 1 expected pending** - full unit suite: **5,178 passing, 34 pending**; the remaining 10 failures are pre-existing/unrelated Windows PATH-casing assertions and native PET timeouts in untouched suites The complete diff also went through two code-review passes and a design-focused review; no production issues remained after addressing malformed-event and active set/clear coverage. ## Follow-up The EnvExt adapter currently relies on change events rather than hydrating an authoritative initial snapshot. That is a separate resilience concern; this PR intentionally targets the user-confirmed `no-python` batch-abort failure. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2f537bc5-b389-4ee2-aac2-e2b83be47f5c
1 parent 02fe7d0 commit e0f1d3c

5 files changed

Lines changed: 354 additions & 54 deletions

File tree

‎src/client/envExt/api.legacy.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { getEnvExtApi, getEnvironment } from './api.internal';
66
import { EnvironmentType, PythonEnvironment as PythonEnvironmentLegacy } from '../pythonEnvironments/info';
77
import { PythonEnvironment, PythonTerminalCreateOptions } from './types';
88
import { Architecture } from '../common/utils/platform';
9-
import { parseVersion } from '../pythonEnvironments/base/info/pythonVersion';
109
import { PythonEnvType } from '../pythonEnvironments/base/info';
1110
import { traceError } from '../logging';
1211
import { reportActiveInterpreterChanged } from '../environmentApi';
1312
import { getWorkspaceFolder, getWorkspaceFolders } from '../common/vscodeApis/workspaceApis';
13+
import { parsePythonEnvironmentVersion } from './utils';
1414

1515
function toEnvironmentType(pythonEnv: PythonEnvironment): EnvironmentType {
1616
if (pythonEnv.envId.managerId.toLowerCase().endsWith('system')) {
@@ -73,8 +73,11 @@ function getEnvType(kind: EnvironmentType): PythonEnvType | undefined {
7373
}
7474
}
7575

76-
function toLegacyType(env: PythonEnvironment): PythonEnvironmentLegacy {
77-
const ver = parseVersion(env.version);
76+
function toLegacyType(env: PythonEnvironment): PythonEnvironmentLegacy | undefined {
77+
const ver = parsePythonEnvironmentVersion(env);
78+
if (!ver) {
79+
return undefined;
80+
}
7881
const envType = toEnvironmentType(env);
7982
return {
8083
id: env.execInfo.run.executable,

‎src/client/envExt/envExtApi.ts‎

Lines changed: 73 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import * as path from 'path';
66
import { Event, EventEmitter, Disposable, Uri } from 'vscode';
7-
import { PythonEnvInfo, PythonEnvKind, PythonEnvType, PythonVersion } from '../pythonEnvironments/base/info';
7+
import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from '../pythonEnvironments/base/info';
88
import {
99
GetRefreshEnvironmentsOptions,
1010
IDiscoveryAPI,
@@ -26,8 +26,8 @@ import {
2626
} from './types';
2727
import { FileChangeType } from '../common/platform/fileSystemWatcher';
2828
import { Architecture, isWindows } from '../common/utils/platform';
29-
import { parseVersion } from '../pythonEnvironments/base/info/pythonVersion';
3029
import { Interpreters } from '../common/utils/localize';
30+
import { parsePythonEnvironmentVersion } from './utils';
3131

3232
function getKind(pythonEnv: PythonEnvironment): PythonEnvKind {
3333
if (pythonEnv.envId.managerId.toLowerCase().endsWith('system')) {
@@ -127,39 +127,51 @@ function getEnvType(kind: PythonEnvKind): PythonEnvType | undefined {
127127
}
128128

129129
function toPythonEnvInfo(pythonEnv: PythonEnvironment): PythonEnvInfo | undefined {
130-
const kind = getKind(pythonEnv);
131-
const arch = Architecture.x64;
132-
const version: PythonVersion = parseVersion(pythonEnv.version);
133-
const { name, displayName, sysPrefix } = pythonEnv;
134-
const executable = getExecutable(pythonEnv);
135-
const location = getLocation(pythonEnv);
136-
137-
return {
138-
name,
139-
location,
140-
kind,
141-
id: executable,
142-
executable: {
143-
filename: executable,
144-
sysPrefix,
145-
ctime: -1,
146-
mtime: -1,
147-
},
148-
version: {
149-
sysVersion: pythonEnv.version,
150-
major: version.major,
151-
minor: version.minor,
152-
micro: version.micro,
153-
},
154-
arch,
155-
distro: {
156-
org: '',
157-
},
158-
source: [],
159-
detailedDisplayName: displayName,
160-
display: displayName,
161-
type: getEnvType(kind),
162-
};
130+
const version = parsePythonEnvironmentVersion(pythonEnv);
131+
if (!version) {
132+
return undefined;
133+
}
134+
135+
try {
136+
const kind = getKind(pythonEnv);
137+
const arch = Architecture.x64;
138+
const { name, displayName, sysPrefix } = pythonEnv;
139+
const executable = getExecutable(pythonEnv);
140+
const location = getLocation(pythonEnv);
141+
142+
return {
143+
name,
144+
location,
145+
kind,
146+
id: executable,
147+
executable: {
148+
filename: executable,
149+
sysPrefix,
150+
ctime: -1,
151+
mtime: -1,
152+
},
153+
version: {
154+
sysVersion: pythonEnv.version,
155+
major: version.major,
156+
minor: version.minor,
157+
micro: version.micro,
158+
},
159+
arch,
160+
distro: {
161+
org: '',
162+
},
163+
source: [],
164+
detailedDisplayName: displayName,
165+
display: displayName,
166+
type: getEnvType(kind),
167+
};
168+
} catch (error) {
169+
traceError(
170+
`Failed to convert environment "${pythonEnv.displayName}" from the Python Environments extension`,
171+
error,
172+
);
173+
return undefined;
174+
}
163175
}
164176

165177
function hasChanged(old: PythonEnvInfo, newEnv: PythonEnvInfo): boolean {
@@ -214,11 +226,16 @@ class EnvExtApis implements IDiscoveryAPI, Disposable {
214226
this._onChanged,
215227
this.envExtApi.onDidChangeEnvironments((e) => this.onDidChangeEnvironments(e)),
216228
this.envExtApi.onDidChangeEnvironment((e) => {
229+
const oldEnv = e.old ? toPythonEnvInfo(e.old) : undefined;
230+
const newEnv = e.new ? toPythonEnvInfo(e.new) : undefined;
231+
if ((e.old && !oldEnv) || (e.new && !newEnv)) {
232+
return;
233+
}
217234
this._onChanged.fire({
218235
type: FileChangeType.Changed,
219236
searchLocation: e.uri,
220-
old: e.old ? toPythonEnvInfo(e.old) : undefined,
221-
new: e.new ? toPythonEnvInfo(e.new) : undefined,
237+
old: oldEnv,
238+
new: newEnv,
222239
});
223240
}),
224241
);
@@ -293,15 +310,11 @@ class EnvExtApis implements IDiscoveryAPI, Disposable {
293310
return info;
294311
}
295312

296-
private removeEnv(env: PythonEnvInfo | string): void {
297-
if (typeof env === 'string') {
298-
const old = this._envs.find((item) => item.executable.filename === env);
299-
this._envs = this._envs.filter((item) => item.executable.filename !== env);
300-
this._onChanged.fire({ type: FileChangeType.Deleted, old });
301-
return;
302-
}
303-
this._envs = this._envs.filter((item) => item.executable.filename !== env.executable.filename);
304-
this._onChanged.fire({ type: FileChangeType.Deleted, old: env });
313+
private removeEnv(env: PythonEnvironment): void {
314+
const executable = getExecutable(env);
315+
const old = this._envs.find((item) => item.executable.filename === executable);
316+
this._envs = this._envs.filter((item) => item.executable.filename !== executable);
317+
this._onChanged.fire({ type: FileChangeType.Deleted, old });
305318
}
306319

307320
async resolveEnv(envPath?: string): Promise<PythonEnvInfo | undefined> {
@@ -328,11 +341,20 @@ class EnvExtApis implements IDiscoveryAPI, Disposable {
328341

329342
onDidChangeEnvironments(e: DidChangeEnvironmentsEventArgs): void {
330343
e.forEach((item) => {
331-
if (item.kind === EnvironmentChangeKind.remove) {
332-
this.removeEnv(item.environment.environmentPath.fsPath);
333-
}
334-
if (item.kind === EnvironmentChangeKind.add) {
335-
this.addEnv(item.environment);
344+
try {
345+
if (item.kind === EnvironmentChangeKind.remove) {
346+
this.removeEnv(item.environment);
347+
}
348+
if (item.kind === EnvironmentChangeKind.add) {
349+
this.addEnv(item.environment);
350+
}
351+
} catch (error) {
352+
traceError(
353+
`Failed to process environment change for "${
354+
item?.environment?.displayName ?? 'unknown environment'
355+
}" from the Python Environments extension`,
356+
error,
357+
);
336358
}
337359
});
338360
}

‎src/client/envExt/utils.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { traceError, traceVerbose } from '../logging';
5+
import { PythonVersion } from '../pythonEnvironments/base/info';
6+
import { parseVersion } from '../pythonEnvironments/base/info/pythonVersion';
7+
import { PythonEnvironment } from './types';
8+
9+
export function parsePythonEnvironmentVersion(pythonEnv: PythonEnvironment): PythonVersion | undefined {
10+
if (pythonEnv.version === 'no-python') {
11+
traceVerbose(`Skipping environment without Python: ${pythonEnv.displayName}`);
12+
return undefined;
13+
}
14+
15+
try {
16+
return parseVersion(pythonEnv.version);
17+
} catch (error) {
18+
traceError(
19+
`Failed to parse version for environment "${pythonEnv.displayName}" from the Python Environments extension`,
20+
error,
21+
);
22+
return undefined;
23+
}
24+
}

‎src/test/envExt/api.legacy.unit.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,20 @@ suite('Env extension legacy API - getActiveInterpreterLegacy', () => {
7272

7373
expect(getEnvironmentStub.callCount).to.equal(2);
7474
});
75+
76+
test('Returns undefined for an environment without Python', async () => {
77+
getEnvironmentStub.resolves(buildEnv('/usr/bin/conda', 'no-python'));
78+
79+
const result = await getActiveInterpreterLegacy(undefined);
80+
81+
expect(result).to.equal(undefined);
82+
});
83+
84+
test('Returns undefined for an environment with an invalid version', async () => {
85+
getEnvironmentStub.resolves(buildEnv('/usr/bin/python', 'not-a-version'));
86+
87+
const result = await getActiveInterpreterLegacy(undefined);
88+
89+
expect(result).to.equal(undefined);
90+
});
7591
});

0 commit comments

Comments
 (0)