Skip to content

Commit d16568e

Browse files
author
Kartik Raj
authored
Ensure resolveEnvironment API resolves the latest details for conda envs without python (#20862)
Closes #20765 Change `resolveEnvironment` API to validate cache for conda envs without python before using it, it also making sure we fire a update event after resolving it and adding it to cache.
1 parent 7ee3f7d commit d16568e

File tree

6 files changed

+149
-28
lines changed

6 files changed

+149
-28
lines changed

src/client/interpreter/interpreterService.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProgressLocation,
1010
ProgressOptions,
1111
Uri,
12+
WorkspaceFolder,
1213
} from 'vscode';
1314
import '../common/extensions';
1415
import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../common/application/types';
@@ -96,7 +97,13 @@ export class InterpreterService implements Disposable, IInterpreterService {
9697
public async refresh(resource?: Uri): Promise<void> {
9798
const interpreterDisplay = this.serviceContainer.get<IInterpreterDisplay>(IInterpreterDisplay);
9899
await interpreterDisplay.refresh(resource);
99-
this.ensureEnvironmentContainsPython(this.configService.getSettings(resource).pythonPath).ignoreErrors();
100+
const workspaceFolder = this.serviceContainer
101+
.get<IWorkspaceService>(IWorkspaceService)
102+
.getWorkspaceFolder(resource);
103+
this.ensureEnvironmentContainsPython(
104+
this.configService.getSettings(resource).pythonPath,
105+
workspaceFolder,
106+
).ignoreErrors();
100107
}
101108

102109
public initialize(): void {
@@ -227,18 +234,21 @@ export class InterpreterService implements Disposable, IInterpreterService {
227234
if (this._pythonPathSetting === '' || this._pythonPathSetting !== pySettings.pythonPath) {
228235
this._pythonPathSetting = pySettings.pythonPath;
229236
this.didChangeInterpreterEmitter.fire(resource);
237+
const workspaceFolder = this.serviceContainer
238+
.get<IWorkspaceService>(IWorkspaceService)
239+
.getWorkspaceFolder(resource);
230240
reportActiveInterpreterChanged({
231241
path: pySettings.pythonPath,
232-
resource: this.serviceContainer.get<IWorkspaceService>(IWorkspaceService).getWorkspaceFolder(resource),
242+
resource: workspaceFolder,
233243
});
234244
const interpreterDisplay = this.serviceContainer.get<IInterpreterDisplay>(IInterpreterDisplay);
235245
interpreterDisplay.refresh().catch((ex) => traceError('Python Extension: display.refresh', ex));
236-
await this.ensureEnvironmentContainsPython(this._pythonPathSetting);
246+
await this.ensureEnvironmentContainsPython(this._pythonPathSetting, workspaceFolder);
237247
}
238248
}
239249

240250
@cache(-1, true)
241-
private async ensureEnvironmentContainsPython(pythonPath: string) {
251+
private async ensureEnvironmentContainsPython(pythonPath: string, workspaceFolder: WorkspaceFolder | undefined) {
242252
const installer = this.serviceContainer.get<IInstaller>(IInstaller);
243253
if (!(await installer.isInstalled(Product.python))) {
244254
// If Python is not installed into the environment, install it.
@@ -251,7 +261,18 @@ export class InterpreterService implements Disposable, IInterpreterService {
251261
traceLog('Conda envs without Python are known to not work well; fixing conda environment...');
252262
const promise = installer.install(Product.python, await this.getInterpreterDetails(pythonPath));
253263
shell.withProgress(progressOptions, () => promise);
254-
promise.then(() => this.triggerRefresh().ignoreErrors());
264+
promise
265+
.then(async () => {
266+
// Fetch interpreter details so the cache is updated to include the newly installed Python.
267+
await this.getInterpreterDetails(pythonPath);
268+
// Fire an event as the executable for the environment has changed.
269+
this.didChangeInterpreterEmitter.fire(workspaceFolder?.uri);
270+
reportActiveInterpreterChanged({
271+
path: pythonPath,
272+
resource: workspaceFolder,
273+
});
274+
})
275+
.ignoreErrors();
255276
}
256277
}
257278
}

src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { Event } from 'vscode';
55
import { isTestExecution } from '../../../../common/constants';
66
import { traceInfo, traceVerbose } from '../../../../logging';
77
import { arePathsSame, getFileInfo, pathExists } from '../../../common/externalDependencies';
8-
import { PythonEnvInfo } from '../../info';
8+
import { PythonEnvInfo, PythonEnvKind } from '../../info';
99
import { areEnvsDeepEqual, areSameEnv, getEnvPath } from '../../info/env';
1010
import {
1111
BasicPythonEnvCollectionChangedEvent,
1212
PythonEnvCollectionChangedEvent,
1313
PythonEnvsWatcher,
1414
} from '../../watcher';
15+
import { getCondaInterpreterPath } from '../../../common/environmentManagers/conda';
1516

1617
export interface IEnvsCollectionCache {
1718
/**
@@ -146,15 +147,18 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher<PythonEnvCollectionCha
146147

147148
public addEnv(env: PythonEnvInfo, hasLatestInfo?: boolean): void {
148149
const found = this.envs.find((e) => areSameEnv(e, env));
150+
if (!found) {
151+
this.envs.push(env);
152+
this.fire({ new: env });
153+
} else if (hasLatestInfo && !this.validatedEnvs.has(env.id!)) {
154+
// Update cache if we have latest info and the env is not already validated.
155+
this.updateEnv(found, env, true);
156+
}
149157
if (hasLatestInfo) {
150158
traceVerbose(`Flushing env to cache ${env.id}`);
151159
this.validatedEnvs.add(env.id!);
152160
this.flush(env).ignoreErrors(); // If we have latest info, flush it so it can be saved.
153161
}
154-
if (!found) {
155-
this.envs.push(env);
156-
this.fire({ new: env });
157-
}
158162
}
159163

160164
public updateEnv(oldValue: PythonEnvInfo, newValue: PythonEnvInfo | undefined, forceUpdate = false): void {
@@ -177,6 +181,20 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher<PythonEnvCollectionCha
177181
public async getLatestInfo(path: string): Promise<PythonEnvInfo | undefined> {
178182
// `path` can either be path to environment or executable path
179183
const env = this.envs.find((e) => arePathsSame(e.location, path)) ?? this.envs.find((e) => areSameEnv(e, path));
184+
if (
185+
env?.kind === PythonEnvKind.Conda &&
186+
getEnvPath(env.executable.filename, env.location).pathType === 'envFolderPath'
187+
) {
188+
if (await pathExists(getCondaInterpreterPath(env.location))) {
189+
// This is a conda env without python in cache which actually now has a valid python, so return
190+
// `undefined` and delete value from cache as cached value is not the latest anymore.
191+
this.validatedEnvs.delete(env.id!);
192+
return undefined;
193+
}
194+
// Do not attempt to validate these envs as they lack an executable, and consider them as validated by default.
195+
this.validatedEnvs.add(env.id!);
196+
return env;
197+
}
180198
if (env) {
181199
if (this.validatedEnvs.has(env.id!)) {
182200
traceVerbose(`Found cached env for ${path}`);

src/test/pythonEnvironments/base/common.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,15 @@ export class SimpleLocator<I = PythonEnvInfo> extends Locator<I> {
104104
constructor(
105105
private envs: I[],
106106
public callbacks: {
107-
resolve?: null | ((env: PythonEnvInfo) => Promise<PythonEnvInfo | undefined>);
107+
resolve?: null | ((env: PythonEnvInfo | string) => Promise<PythonEnvInfo | undefined>);
108108
before?(): Promise<void>;
109109
after?(): Promise<void>;
110110
onUpdated?: Event<PythonEnvUpdatedEvent<I> | ProgressNotificationEvent>;
111111
beforeEach?(e: I): Promise<void>;
112112
afterEach?(e: I): Promise<void>;
113113
onQuery?(query: PythonLocatorQuery | undefined, envs: I[]): Promise<I[]>;
114114
} = {},
115+
private options?: { resolveAsString?: boolean },
115116
) {
116117
super();
117118
}
@@ -172,7 +173,7 @@ export class SimpleLocator<I = PythonEnvInfo> extends Locator<I> {
172173
if (this.callbacks?.resolve === null) {
173174
return undefined;
174175
}
175-
return this.callbacks.resolve(envInfo);
176+
return this.callbacks.resolve(this.options?.resolveAsString ? env : envInfo);
176177
}
177178
}
178179

src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
12
// Copyright (c) Microsoft Corporation. All rights reserved.
23
// Licensed under the MIT License.
34

@@ -22,13 +23,31 @@ import * as externalDependencies from '../../../../../client/pythonEnvironments/
2223
import { noop } from '../../../../core';
2324
import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants';
2425
import { SimpleLocator } from '../../common';
25-
import { assertEnvEqual, assertEnvsEqual } from '../envTestUtils';
26+
import { assertEnvEqual, assertEnvsEqual, createFile, deleteFile } from '../envTestUtils';
27+
import { OSType, getOSType } from '../../../../common';
2628

2729
suite('Python envs locator - Environments Collection', async () => {
2830
let collectionService: EnvsCollectionService;
2931
let storage: PythonEnvInfo[];
3032

3133
const updatedName = 'updatedName';
34+
const pathToCondaPython = getOSType() === OSType.Windows ? 'python.exe' : path.join('bin', 'python');
35+
const condaEnvWithoutPython = createEnv(
36+
'python',
37+
undefined,
38+
undefined,
39+
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'),
40+
PythonEnvKind.Conda,
41+
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython),
42+
);
43+
const condaEnvWithPython = createEnv(
44+
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython),
45+
undefined,
46+
undefined,
47+
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'),
48+
PythonEnvKind.Conda,
49+
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython),
50+
);
3251

3352
function applyChangeEventToEnvList(envs: PythonEnvInfo[], event: PythonEnvCollectionChangedEvent) {
3453
const env = event.old ?? event.new;
@@ -49,8 +68,17 @@ suite('Python envs locator - Environments Collection', async () => {
4968
return envs;
5069
}
5170

52-
function createEnv(executable: string, searchLocation?: Uri, name?: string, location?: string) {
53-
return buildEnvInfo({ executable, searchLocation, name, location });
71+
function createEnv(
72+
executable: string,
73+
searchLocation?: Uri,
74+
name?: string,
75+
location?: string,
76+
kind?: PythonEnvKind,
77+
id?: string,
78+
) {
79+
const env = buildEnvInfo({ executable, searchLocation, name, location, kind });
80+
env.id = id ?? env.id;
81+
return env;
5482
}
5583

5684
function getLocatorEnvs() {
@@ -77,12 +105,7 @@ suite('Python envs locator - Environments Collection', async () => {
77105
path.join(TEST_LAYOUT_ROOT, 'pipenv', 'project1', '.venv', 'Scripts', 'python.exe'),
78106
Uri.file(TEST_LAYOUT_ROOT),
79107
);
80-
const envCached3 = createEnv(
81-
'python',
82-
undefined,
83-
undefined,
84-
path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'),
85-
);
108+
const envCached3 = condaEnvWithoutPython;
86109
return [cachedEnvForWorkspace, envCached1, envCached2, envCached3];
87110
}
88111

@@ -123,7 +146,8 @@ suite('Python envs locator - Environments Collection', async () => {
123146
collectionService = new EnvsCollectionService(cache, parentLocator);
124147
});
125148

126-
teardown(() => {
149+
teardown(async () => {
150+
await deleteFile(condaEnvWithPython.executable.filename); // Restore to the original state
127151
sinon.restore();
128152
});
129153

@@ -404,7 +428,7 @@ suite('Python envs locator - Environments Collection', async () => {
404428
env.executable.mtime = 100;
405429
sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 });
406430
const parentLocator = new SimpleLocator([], {
407-
resolve: async (e: PythonEnvInfo) => {
431+
resolve: async (e: any) => {
408432
if (env.executable.filename === e.executable.filename) {
409433
return resolvedViaLocator;
410434
}
@@ -434,7 +458,7 @@ suite('Python envs locator - Environments Collection', async () => {
434458
waitDeferred.resolve();
435459
await deferred.promise;
436460
},
437-
resolve: async (e: PythonEnvInfo) => {
461+
resolve: async (e: any) => {
438462
if (env.executable.filename === e.executable.filename) {
439463
return resolvedViaLocator;
440464
}
@@ -464,7 +488,7 @@ suite('Python envs locator - Environments Collection', async () => {
464488
env.executable.mtime = 90;
465489
sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 });
466490
const parentLocator = new SimpleLocator([], {
467-
resolve: async (e: PythonEnvInfo) => {
491+
resolve: async (e: any) => {
468492
if (env.executable.filename === e.executable.filename) {
469493
return resolvedViaLocator;
470494
}
@@ -483,7 +507,7 @@ suite('Python envs locator - Environments Collection', async () => {
483507
test('resolveEnv() adds env to cache after resolving using downstream locator', async () => {
484508
const resolvedViaLocator = buildEnvInfo({ executable: 'Resolved via locator' });
485509
const parentLocator = new SimpleLocator([], {
486-
resolve: async (e: PythonEnvInfo) => {
510+
resolve: async (e: any) => {
487511
if (resolvedViaLocator.executable.filename === e.executable.filename) {
488512
return resolvedViaLocator;
489513
}
@@ -500,6 +524,49 @@ suite('Python envs locator - Environments Collection', async () => {
500524
assertEnvsEqual(envs, [resolved]);
501525
});
502526

527+
test('resolveEnv() uses underlying locator once conda envs without python get a python installed', async () => {
528+
const cachedEnvs = [condaEnvWithoutPython];
529+
const parentLocator = new SimpleLocator(
530+
[],
531+
{
532+
resolve: async (e) => {
533+
if (condaEnvWithoutPython.location === (e as string)) {
534+
return condaEnvWithPython;
535+
}
536+
return undefined;
537+
},
538+
},
539+
{ resolveAsString: true },
540+
);
541+
const cache = await createCollectionCache({
542+
get: () => cachedEnvs,
543+
store: async () => noop(),
544+
});
545+
collectionService = new EnvsCollectionService(cache, parentLocator);
546+
let resolved = await collectionService.resolveEnv(condaEnvWithoutPython.location);
547+
assertEnvEqual(resolved, condaEnvWithoutPython); // Ensure cache is used to resolve such envs.
548+
549+
condaEnvWithPython.executable.ctime = 100;
550+
condaEnvWithPython.executable.mtime = 100;
551+
sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 });
552+
553+
const events: PythonEnvCollectionChangedEvent[] = [];
554+
collectionService.onChanged((e) => {
555+
events.push(e);
556+
});
557+
558+
await createFile(condaEnvWithPython.executable.filename); // Install Python into the env
559+
560+
resolved = await collectionService.resolveEnv(condaEnvWithoutPython.location);
561+
assertEnvEqual(resolved, condaEnvWithPython); // Ensure it resolves latest info.
562+
563+
// Verify conda env without python in cache is replaced with updated info.
564+
const envs = collectionService.getEnvs();
565+
assertEnvsEqual(envs, [condaEnvWithPython]);
566+
567+
expect(events.length).to.equal(1, 'Update event should be fired');
568+
});
569+
503570
test('Ensure events from downstream locators do not trigger new refreshes if a refresh is already scheduled', async () => {
504571
const refreshDeferred = createDeferred();
505572
let refreshCount = 0;

src/test/pythonEnvironments/base/locators/envTestUtils.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
import * as fsapi from 'fs-extra';
45
import * as assert from 'assert';
56
import { exec } from 'child_process';
6-
import { zip } from 'lodash';
7+
import { cloneDeep, zip } from 'lodash';
78
import { promisify } from 'util';
89
import { PythonEnvInfo, PythonVersion, UNKNOWN_PYTHON_VERSION } from '../../../../client/pythonEnvironments/base/info';
910
import { getEmptyVersion } from '../../../../client/pythonEnvironments/base/info/pythonVersion';
@@ -40,17 +41,30 @@ export function assertVersionsEqual(actual: PythonVersion | undefined, expected:
4041
assert.deepStrictEqual(actual, expected);
4142
}
4243

44+
export async function createFile(filename: string, text = ''): Promise<string> {
45+
await fsapi.writeFile(filename, text);
46+
return filename;
47+
}
48+
49+
export async function deleteFile(filename: string): Promise<void> {
50+
await fsapi.remove(filename);
51+
}
52+
4353
export function assertEnvEqual(actual: PythonEnvInfo | undefined, expected: PythonEnvInfo | undefined): void {
4454
assert.notStrictEqual(actual, undefined);
4555
assert.notStrictEqual(expected, undefined);
4656

4757
if (actual) {
58+
// Make sure to clone so we do not alter the original object
59+
actual = cloneDeep(actual);
60+
expected = cloneDeep(expected);
4861
// No need to match these, so reset them
4962
actual.executable.ctime = -1;
5063
actual.executable.mtime = -1;
51-
5264
actual.version = normalizeVersion(actual.version);
5365
if (expected) {
66+
expected.executable.ctime = -1;
67+
expected.executable.mtime = -1;
5468
expected.version = normalizeVersion(expected.version);
5569
delete expected.id;
5670
}

src/test/pythonEnvironments/common/envlayouts/envsWithoutPython/condaLackingPython/bin/dummy

Whitespace-only changes.

0 commit comments

Comments
 (0)