diff --git a/src/client/interpreter/autoSelection/index.ts b/src/client/interpreter/autoSelection/index.ts index 5bc13628e3b5..1340c0c4daee 100644 --- a/src/client/interpreter/autoSelection/index.ts +++ b/src/client/interpreter/autoSelection/index.ts @@ -6,15 +6,18 @@ import { inject, injectable, named } from 'inversify'; import { Event, EventEmitter, Uri } from 'vscode'; import { IWorkspaceService } from '../../common/application/types'; +import { EnvironmentSorting } from '../../common/experiments/groups'; import '../../common/extensions'; import { IFileSystem } from '../../common/platform/types'; -import { IPersistentState, IPersistentStateFactory, Resource } from '../../common/types'; +import { IExperimentService, IPersistentState, IPersistentStateFactory, Resource } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; import { compareSemVerLikeVersions } from '../../pythonEnvironments/base/info/pythonVersion'; import { PythonEnvironment } from '../../pythonEnvironments/info'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; -import { IInterpreterHelper } from '../contracts'; +import { EnvTypeHeuristic, getEnvTypeHeuristic } from '../configuration/environmentTypeComparer'; +import { InterpreterComparisonType, IInterpreterComparer } from '../configuration/types'; +import { IInterpreterHelper, IInterpreterService } from '../contracts'; import { AutoSelectionRule, IInterpreterAutoSelectionRule, @@ -46,6 +49,11 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio @inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService, @inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory, @inject(IFileSystem) private readonly fs: IFileSystem, + @inject(IExperimentService) private readonly experimentService: IExperimentService, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, + @inject(IInterpreterComparer) + @named(InterpreterComparisonType.EnvType) + private readonly envTypeComparer: IInterpreterComparer, @inject(IInterpreterAutoSelectionRule) @named(AutoSelectionRule.systemWide) systemInterpreter: IInterpreterAutoSelectionRule, @@ -104,19 +112,30 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio winRegInterpreter.setNextRule(systemInterpreter); } + /** + * If there's a cached auto-selected interpreter -> return it. + * If not, check if we are in the env sorting experiment, and use the appropriate auto-selection logic. + */ @captureTelemetry(EventName.PYTHON_INTERPRETER_AUTO_SELECTION, { rule: AutoSelectionRule.all }, true) public async autoSelectInterpreter(resource: Resource): Promise { const key = this.getWorkspacePathKey(resource); + if (!this.autoSelectedWorkspacePromises.has(key)) { const deferred = createDeferred(); this.autoSelectedWorkspacePromises.set(key, deferred); + await this.initializeStore(resource); await this.clearWorkspaceStoreIfInvalid(resource); - await this.userDefinedInterpreter.autoSelectInterpreter(resource, this); - this.didAutoSelectedInterpreterEmitter.fire(); - Promise.all(this.rules.map((item) => item.autoSelectInterpreter(resource))).ignoreErrors(); + + if (await this.experimentService.inExperiment(EnvironmentSorting.experiment)) { + await this.autoselectInterpreterWithLocators(resource); + } else { + await this.autoselectInterpreterWithRules(resource); + } + deferred.resolve(); } + return this.autoSelectedWorkspacePromises.get(key)!.promise; } @@ -221,4 +240,44 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio } return undefined; } + + private async autoselectInterpreterWithRules(resource: Resource): Promise { + await this.userDefinedInterpreter.autoSelectInterpreter(resource, this); + + this.didAutoSelectedInterpreterEmitter.fire(); + + Promise.all(this.rules.map((item) => item.autoSelectInterpreter(resource))).ignoreErrors(); + } + + /** + * Auto-selection logic: + * 1. If there are cached interpreters (not the first session in this workspace) + * -> sort using the same logic as in the interpreter quickpick and return the first one; + * 2. If not, we already fire all the locators, so wait for their response, sort the interpreters and return the first one. + * + * `getInterpreters` will check the cache first and return early if there are any cached interpreters, + * and if not it will wait for locators to return. + * As such, we can sort interpreters based on what it returns. + */ + private async autoselectInterpreterWithLocators(resource: Resource): Promise { + const interpreters = await this.interpreterService.getInterpreters(resource); + const workspaceUri = this.interpreterHelper.getActiveWorkspaceUri(resource); + + // When auto-selecting an intepreter for a workspace, we either want to return a local one + // or fallback on a globally-installed interpreter, and we don't want want to suggest a global environment + // because we would have to add a way to match environments to a workspace. + const filteredInterpreters = interpreters.filter( + (i) => getEnvTypeHeuristic(i, workspaceUri?.folderUri.fsPath || '') !== EnvTypeHeuristic.Global, + ); + + filteredInterpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); + + if (workspaceUri) { + this.setWorkspaceInterpreter(workspaceUri.folderUri, filteredInterpreters[0]); + } else { + this.setGlobalInterpreter(filteredInterpreters[0]); + } + + this.didAutoSelectedInterpreterEmitter.fire(); + } } diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts index bf73d96d00bd..ac0eaae0cd9a 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -15,7 +15,7 @@ import { IInterpreterComparer } from './types'; * - Global environments (pipenv, conda); * - Globally-installed interpreters (/usr/bin/python3, Windows Store). */ -enum EnvTypeHeuristic { +export enum EnvTypeHeuristic { Local = 1, Global = 2, GlobalInterpreters = 3, @@ -165,7 +165,7 @@ function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, work /** * Return a heuristic value depending on the environment type. */ -function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: string): EnvTypeHeuristic { +export function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: string): EnvTypeHeuristic { const { envType } = environment; if (workspacePath.length > 0 && environment.envPath && isParentPath(environment.envPath, workspacePath)) { diff --git a/src/test/configuration/environmentTypeComparer.unit.test.ts b/src/test/configuration/environmentTypeComparer.unit.test.ts index 66699006dd60..ef4fc630e5f6 100644 --- a/src/test/configuration/environmentTypeComparer.unit.test.ts +++ b/src/test/configuration/environmentTypeComparer.unit.test.ts @@ -4,7 +4,11 @@ import * as assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { EnvironmentTypeComparer } from '../../client/interpreter/configuration/environmentTypeComparer'; +import { + EnvironmentTypeComparer, + EnvTypeHeuristic, + getEnvTypeHeuristic, +} from '../../client/interpreter/configuration/environmentTypeComparer'; import { IInterpreterHelper } from '../../client/interpreter/contracts'; import { EnvironmentType, PythonEnvironment } from '../../client/pythonEnvironments/info'; @@ -225,3 +229,64 @@ suite('Environment sorting', () => { }); }); }); + +suite('getEnvTypeHeuristic tests', () => { + const workspacePath = path.join('path', 'to', 'workspace'); + + const localGlobalEnvTypes = [ + EnvironmentType.Venv, + EnvironmentType.Conda, + EnvironmentType.VirtualEnv, + EnvironmentType.VirtualEnvWrapper, + EnvironmentType.Pipenv, + EnvironmentType.Poetry, + ]; + + localGlobalEnvTypes.forEach((envType) => { + test('If the path to an environment starts with the workspace path it should be marked as local', () => { + const environment = { + envType, + envPath: path.join(workspacePath, 'my-environment'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment; + + const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + + assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.Local); + }); + + test('If the path to an environment does not start with the workspace path it should be marked as global', () => { + const environment = { + envType, + envPath: path.join('path', 'to', 'my-environment'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment; + + const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + + assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.Global); + }); + }); + + const globalInterpretersEnvTypes = [ + EnvironmentType.System, + EnvironmentType.WindowsStore, + EnvironmentType.Global, + EnvironmentType.Unknown, + EnvironmentType.Pyenv, + ]; + + globalInterpretersEnvTypes.forEach((envType) => { + test(`If the environment type is ${envType} and the environment path does not start with the workspace path it should be marked as a global interpreter`, () => { + const environment = { + envType, + envPath: path.join('path', 'to', 'a', 'global', 'interpreter'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment; + + const envTypeHeuristic = getEnvTypeHeuristic(environment, workspacePath); + + assert.strictEqual(envTypeHeuristic, EnvTypeHeuristic.GlobalInterpreters); + }); + }); +}); diff --git a/src/test/interpreters/autoSelection/index.unit.test.ts b/src/test/interpreters/autoSelection/index.unit.test.ts index 4c05d91afd5e..e56db655139b 100644 --- a/src/test/interpreters/autoSelection/index.unit.test.ts +++ b/src/test/interpreters/autoSelection/index.unit.test.ts @@ -4,11 +4,13 @@ 'use strict'; import { expect } from 'chai'; +import * as path from 'path'; import { SemVer } from 'semver'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; import { Uri } from 'vscode'; import { IWorkspaceService } from '../../../client/common/application/types'; import { WorkspaceService } from '../../../client/common/application/workspace'; +import { EnvironmentSorting } from '../../../client/common/experiments/groups'; import { ExperimentService } from '../../../client/common/experiments/service'; import { PersistentState, PersistentStateFactory } from '../../../client/common/persistentState'; import { FileSystem } from '../../../client/common/platform/fileSystem'; @@ -27,9 +29,11 @@ import { IInterpreterAutoSelectionRule, IInterpreterAutoSelectionProxyService, } from '../../../client/interpreter/autoSelection/types'; -import { IInterpreterHelper } from '../../../client/interpreter/contracts'; +import { EnvironmentTypeComparer } from '../../../client/interpreter/configuration/environmentTypeComparer'; +import { IInterpreterHelper, IInterpreterService, WorkspacePythonPath } from '../../../client/interpreter/contracts'; import { InterpreterHelper } from '../../../client/interpreter/helpers'; -import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { InterpreterService } from '../../../client/interpreter/interpreterService'; +import { EnvironmentType, PythonEnvironment } from '../../../client/pythonEnvironments/info'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -50,6 +54,7 @@ suite('Interpreters - Auto Selection', () => { let helper: IInterpreterHelper; let proxy: IInterpreterAutoSelectionProxyService; let experiments: IExperimentService; + let interpreterService: IInterpreterService; class InterpreterAutoSelectionServiceTest extends InterpreterAutoSelectionService { public initializeStore(resource: Resource): Promise { return super.initializeStore(resource); @@ -77,12 +82,19 @@ suite('Interpreters - Auto Selection', () => { helper = mock(InterpreterHelper); proxy = mock(InterpreterAutoSelectionProxyService); experiments = mock(ExperimentService); + interpreterService = mock(InterpreterService); + + const interpreterComparer = new EnvironmentTypeComparer(instance(helper)); + when(experiments.inExperimentSync(anything())).thenReturn(false); autoSelectionService = new InterpreterAutoSelectionServiceTest( instance(workspaceService), instance(stateFactory), instance(fs), + instance(experiments), + instance(interpreterService), + interpreterComparer, instance(systemInterpreter), instance(currentPathInterpreter), instance(winRegInterpreter), @@ -105,44 +117,143 @@ suite('Interpreters - Auto Selection', () => { verify(winRegInterpreter.setNextRule(instance(systemInterpreter))).once(); verify(systemInterpreter.setNextRule(anything())).never(); }); - test('Run rules in background', async () => { - let eventFired = false; - autoSelectionService.onDidChangeAutoSelectedInterpreter(() => { - eventFired = true; - }); - autoSelectionService.initializeStore = () => Promise.resolve(); - await autoSelectionService.autoSelectInterpreter(undefined); - expect(eventFired).to.deep.equal(true, 'event not fired'); + suite('When using rule-based auto-selection', () => { + setup(() => { + when(experiments.inExperiment(EnvironmentSorting.experiment)).thenResolve(false); + }); - const allRules = [ - userDefinedInterpreter, - winRegInterpreter, - currentPathInterpreter, - systemInterpreter, - workspaceInterpreter, - cachedPaths, - ]; - for (const service of allRules) { - verify(service.autoSelectInterpreter(undefined)).once(); - if (service !== userDefinedInterpreter) { - verify(service.autoSelectInterpreter(anything(), autoSelectionService)).never(); + test('Run rules in background', async () => { + let eventFired = false; + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => { + eventFired = true; + }); + autoSelectionService.initializeStore = () => Promise.resolve(); + await autoSelectionService.autoSelectInterpreter(undefined); + + expect(eventFired).to.deep.equal(true, 'event not fired'); + + const allRules = [ + userDefinedInterpreter, + winRegInterpreter, + currentPathInterpreter, + systemInterpreter, + workspaceInterpreter, + cachedPaths, + ]; + for (const service of allRules) { + verify(service.autoSelectInterpreter(undefined)).once(); + if (service !== userDefinedInterpreter) { + verify(service.autoSelectInterpreter(anything(), autoSelectionService)).never(); + } } - } - verify(userDefinedInterpreter.autoSelectInterpreter(anything(), autoSelectionService)).once(); + verify(userDefinedInterpreter.autoSelectInterpreter(anything(), autoSelectionService)).once(); + }); + + test('Run userDefineInterpreter as the first rule', async () => { + let eventFired = false; + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => { + eventFired = true; + }); + autoSelectionService.initializeStore = () => Promise.resolve(); + + await autoSelectionService.autoSelectInterpreter(undefined); + + expect(eventFired).to.deep.equal(true, 'event not fired'); + verify(userDefinedInterpreter.autoSelectInterpreter(undefined, autoSelectionService)).once(); + }); }); - test('Run userDefineInterpreter as the first rule', async () => { - let eventFired = false; - autoSelectionService.onDidChangeAutoSelectedInterpreter(() => { - eventFired = true; + + suite('When using locator-based auto-selection', () => { + let workspacePath: string; + let resource: Uri; + let eventFired: boolean; + + setup(() => { + workspacePath = path.join('path', 'to', 'workspace'); + resource = Uri.parse('resource'); + eventFired = false; + + const folderUri = { fsPath: workspacePath }; + + when(helper.getActiveWorkspaceUri(anything())).thenReturn({ + folderUri, + } as WorkspacePythonPath); + when( + stateFactory.createWorkspacePersistentState(anyString(), undefined), + ).thenReturn(instance(state)); + when( + stateFactory.createGlobalPersistentState( + preferredGlobalInterpreter, + undefined, + ), + ).thenReturn(instance(state)); + when(workspaceService.getWorkspaceFolderIdentifier(anything(), '')).thenReturn('workspaceIdentifier'); + when(experiments.inExperiment(EnvironmentSorting.experiment)).thenResolve(true); + + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => { + eventFired = true; + }); + autoSelectionService.initializeStore = () => Promise.resolve(); }); - autoSelectionService.initializeStore = () => Promise.resolve(); - await autoSelectionService.autoSelectInterpreter(undefined); + test('If there is a local environment select it', async () => { + const localEnv = { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.venv'), + version: { major: 3, minor: 10, patch: 0 }, + } as PythonEnvironment; + + when(interpreterService.getInterpreters(resource)).thenResolve([ + { + envType: EnvironmentType.Conda, + envPath: path.join('some', 'conda', 'env'), + version: { major: 3, minor: 7, patch: 2 }, + } as PythonEnvironment, + { + envType: EnvironmentType.System, + envPath: path.join('/', 'usr', 'bin'), + version: { major: 3, minor: 9, patch: 1 }, + } as PythonEnvironment, + localEnv, + ]); + + await autoSelectionService.autoSelectInterpreter(resource); + + expect(eventFired).to.deep.equal(true, 'event not fired'); + verify(interpreterService.getInterpreters(resource)).once(); + verify(state.updateValue(localEnv)).once(); + }); - expect(eventFired).to.deep.equal(true, 'event not fired'); - verify(userDefinedInterpreter.autoSelectInterpreter(undefined, autoSelectionService)).once(); + test('If there are no local environments, return a globally-installed interpreter', async () => { + const systemEnv = { + envType: EnvironmentType.System, + envPath: path.join('/', 'usr', 'bin'), + version: { major: 3, minor: 9, patch: 1 }, + } as PythonEnvironment; + + when(interpreterService.getInterpreters(resource)).thenResolve([ + { + envType: EnvironmentType.Conda, + envPath: path.join('some', 'conda', 'env'), + version: { major: 3, minor: 7, patch: 2 }, + } as PythonEnvironment, + systemEnv, + { + envType: EnvironmentType.Pipenv, + envPath: path.join('some', 'pipenv', 'env'), + version: { major: 3, minor: 10, patch: 0 }, + } as PythonEnvironment, + ]); + + await autoSelectionService.autoSelectInterpreter(resource); + + expect(eventFired).to.deep.equal(true, 'event not fired'); + verify(interpreterService.getInterpreters(resource)).once(); + verify(state.updateValue(systemEnv)).once(); + }); }); + test('Initialize the store', async () => { let initialize = false; let eventFired = false; @@ -200,6 +311,12 @@ suite('Interpreters - Auto Selection', () => { undefined, ), ).thenReturn(instance(state)); + when( + stateFactory.createGlobalPersistentState( + preferredGlobalInterpreter, + undefined, + ), + ).thenReturn(instance(state)); when(state.value).thenReturn(interpreterInfo); when(fs.fileExists(pythonPath)).thenResolve(true);