From 9eaa95060b33ba093eefee4c2d8d472e3e194e3e Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 23 Jun 2021 11:49:41 -0700 Subject: [PATCH 01/14] New comparison logic --- .../configuration/environmentTypeComparer.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/client/interpreter/configuration/environmentTypeComparer.ts diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts new file mode 100644 index 000000000000..4eb8d49b6b06 --- /dev/null +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { injectable, inject } from 'inversify'; +import { getArchitectureDisplayName } from '../../common/platform/registry'; +import { EnvironmentType, PythonEnvironment } from '../../pythonEnvironments/info'; +import { PythonVersion } from '../../pythonEnvironments/info/pythonVersion'; +import { IInterpreterHelper } from '../contracts'; +import { IInterpreterComparer } from './types'; + +/* + * Order: + * - Local environments (i.e `.venv`); + * - Global environments (pipenv, conda), with conda environments at a lower priority, and "base" being last; + * - Globally-installed interpreters (`/usr/bin/python3`). + */ +enum EnvTypeHeuristic { + Local = 1, + Global = 2, + GlobalInterpreters = 3, +} + +@injectable() +export class EnvironmentTypeComparer implements IInterpreterComparer { + private workspaceFolderPath: string; + + constructor(@inject(IInterpreterHelper) private readonly interpreterHelper: IInterpreterHelper) { + this.workspaceFolderPath = this.interpreterHelper.getActiveWorkspaceUri(undefined)?.folderUri.fsPath ?? ''; + } + + /** + * Compare 2 Python environments, sorting them by assumed usefulness. + * Return 0 if both environments are equal, -1 if a should be closer to the beginning of the list, or 1 if a comes after b. + * + * The comparison guidelines are: + * 1. Local environments first (i.e `.venv`); + * 2. Global environments next (conda); + * 3. Globally-installed interpreters (`/usr/bin/python3`). + * + * Always sort with newest version of Python first within each subgroup. + */ + public compare(a: PythonEnvironment, b: PythonEnvironment): number { + // Check environment type. + const envTypeComparison = compareEnvironmentType(a, b, this.workspaceFolderPath); + if (envTypeComparison !== 0) { + return envTypeComparison; + } + + // Check Python version. + const versionComparison = comparePythonVersion(a.version, b.version); + if (versionComparison !== 0) { + return versionComparison; + } + + // Prioritize non-Conda environments. + if (isCondaEnvironment(a) && !isCondaEnvironment(b)) { + return 1; + } + + if (!isCondaEnvironment(a) && isCondaEnvironment(b)) { + return -1; + } + + // If we have the "base" Conda env, put it last in its Python version subgroup. + if (isBaseCondaEnvironment(a)) { + return 1; + } + + // Check alphabetical order (same way as the InterpreterComparer class). + const nameA = this.getSortName(a); + const nameB = this.getSortName(b); + if (nameA === nameB) { + return 0; + } + + return nameA > nameB ? 1 : -1; + } + + private getSortName(info: PythonEnvironment): string { + const sortNameParts: string[] = []; + const envSuffixParts: string[] = []; + + // Sort order for interpreters is: + // * Version + // * Architecture + // * Interpreter Type + // * Environment name + if (info.version) { + sortNameParts.push(info.version.raw); + } + if (info.architecture) { + sortNameParts.push(getArchitectureDisplayName(info.architecture)); + } + if (info.companyDisplayName && info.companyDisplayName.length > 0) { + sortNameParts.push(info.companyDisplayName.trim()); + } else { + sortNameParts.push('Python'); + } + + if (info.envType) { + const name = this.interpreterHelper.getInterpreterTypeDisplayName(info.envType); + if (name) { + envSuffixParts.push(name); + } + } + if (info.envName && info.envName.length > 0) { + envSuffixParts.push(info.envName); + } + + const envSuffix = envSuffixParts.length === 0 ? '' : `(${envSuffixParts.join(': ')})`; + return `${sortNameParts.join(' ')} ${envSuffix}`.trim(); + } +} + +function isCondaEnvironment(environment: PythonEnvironment): boolean { + return environment.envType === EnvironmentType.Conda; +} + +function isBaseCondaEnvironment(environment: PythonEnvironment): boolean { + return isCondaEnvironment(environment) && (environment.envName === 'base' || environment.envName === 'miniconda'); +} + +/** + * Compare 2 Python versions in decending order, most recent one comes first. + */ +function comparePythonVersion(a: PythonVersion | undefined, b: PythonVersion | undefined): number { + if (!a) { + return 1; + } + + if (!b) { + return -1; + } + + if (a.raw === b.raw) { + return 0; + } + + if (a.major === b.major) { + if (a.minor === b.minor) { + if (a.patch === b.patch) { + return a.build.join(' ') > b.build.join(' ') ? -1 : 1; + } + return a.patch > b.patch ? -1 : 1; + } + return a.minor > b.minor ? -1 : 1; + } + + return a.major > b.major ? -1 : 1; +} + +/** + * Compare 2 environment types, return 0 if they are the same, -1 if a comes before b, 1 otherwise. + */ +function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, workspacePath: string): number { + const aHeuristic = getEnvTypeHeuristic(a, workspacePath); + const bHeuristic = getEnvTypeHeuristic(b, workspacePath); + + return Math.sign(aHeuristic - bHeuristic); +} + +/** + * Returns a heuristic value depending on the environment type: + * - 1: Local environments + * - 2: Global environments + * - 3: Global interpreters + */ +function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: string): EnvTypeHeuristic { + const { envType } = environment; + + switch (envType) { + case EnvironmentType.Venv: { + if (workspacePath.length > 0 && environment.path.startsWith(workspacePath)) { + return EnvTypeHeuristic.Local; + } + return EnvTypeHeuristic.Global; + } + case EnvironmentType.Conda: + case EnvironmentType.VirtualEnv: + case EnvironmentType.VirtualEnvWrapper: + case EnvironmentType.Pipenv: + case EnvironmentType.Poetry: + return EnvTypeHeuristic.Global; + // The default case covers global environments. + // For now this includes: pyenv, Windows Store and everything under "Global", "System" and "Unknown". + default: + return EnvTypeHeuristic.GlobalInterpreters; + } +} From 075fd574ad2e53712ade64449c6cf91fb471a497 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 23 Jun 2021 11:49:51 -0700 Subject: [PATCH 02/14] Add experiment group --- src/client/common/experiments/groups.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/client/common/experiments/groups.ts b/src/client/common/experiments/groups.ts index 602b2ccf0860..1233e948b67c 100644 --- a/src/client/common/experiments/groups.ts +++ b/src/client/common/experiments/groups.ts @@ -43,3 +43,8 @@ export enum FindInterpreterVariants { export enum TorchProfiler { experiment = 'PythonPyTorchProfiler', } + +// Experiment to use the new environment sorting algorithm in the interpreter quickpick. +export enum EnvironmentSorting { + experiment = 'pythonSortEnvs', +} From 6f5447e21858faaf163d54eb9a0fdaf63c463a36 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 23 Jun 2021 11:50:05 -0700 Subject: [PATCH 03/14] Register and call it --- .../interpreterSelector.ts | 29 +++++++++++++++---- src/client/interpreter/configuration/types.ts | 3 ++ src/client/interpreter/serviceRegistry.ts | 10 ++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts index 6bb63c3476b1..38704531ff28 100644 --- a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts +++ b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts @@ -3,12 +3,19 @@ 'use strict'; -import { inject, injectable } from 'inversify'; +import { inject, injectable, named } from 'inversify'; import { Disposable, Uri } from 'vscode'; -import { IPathUtils, Resource } from '../../../common/types'; +import { EnvironmentSorting } from '../../../common/experiments/groups'; +import { IExperimentService, IPathUtils, Resource } from '../../../common/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { IInterpreterService } from '../../contracts'; -import { IInterpreterComparer, IInterpreterQuickPickItem, IInterpreterSelector } from '../types'; +import { + DEFAULT_COMPARISON, + ENV_TYPE_COMPARISON, + IInterpreterComparer, + IInterpreterQuickPickItem, + IInterpreterSelector, +} from '../types'; @injectable() export class InterpreterSelector implements IInterpreterSelector { @@ -16,8 +23,14 @@ export class InterpreterSelector implements IInterpreterSelector { constructor( @inject(IInterpreterService) private readonly interpreterManager: IInterpreterService, - @inject(IInterpreterComparer) private readonly interpreterComparer: IInterpreterComparer, + @inject(IInterpreterComparer) + @named(DEFAULT_COMPARISON) + private readonly interpreterComparer: IInterpreterComparer, + @inject(IInterpreterComparer) + @named(ENV_TYPE_COMPARISON) + private readonly envTypeComparer: IInterpreterComparer, @inject(IPathUtils) private readonly pathUtils: IPathUtils, + @inject(IExperimentService) private readonly experimentService: IExperimentService, ) {} public dispose(): void { @@ -29,7 +42,13 @@ export class InterpreterSelector implements IInterpreterSelector { onSuggestion: true, ignoreCache, }); - interpreters.sort(this.interpreterComparer.compare.bind(this.interpreterComparer)); + + if (await this.experimentService.inExperiment(EnvironmentSorting.experiment)) { + interpreters.sort(this.envTypeComparer.compare.bind(this.envTypeComparer)); + } else { + interpreters.sort(this.interpreterComparer.compare.bind(this.interpreterComparer)); + } + return Promise.all(interpreters.map((item) => this.suggestionToQuickPickItem(item, resource))); } diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index cd121a28f876..5af610bec183 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -45,6 +45,9 @@ export interface IFindInterpreterQuickPickItem { alwaysShow: boolean; } +export const DEFAULT_COMPARISON = Symbol('DefaultEnvironmentComparison'); +export const ENV_TYPE_COMPARISON = Symbol('EnvironmentTypeComparison'); + export const IInterpreterComparer = Symbol('IInterpreterComparer'); export interface IInterpreterComparer { compare(a: PythonEnvironment, b: PythonEnvironment): number; diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index 2b0fb02c06b1..d61e2708dc98 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -21,6 +21,7 @@ import { IInterpreterAutoSelectionService, IInterpreterAutoSelectionProxyService, } from './autoSelection/types'; +import { EnvironmentTypeComparer } from './configuration/environmentTypeComparer'; import { InterpreterComparer } from './configuration/interpreterComparer'; import { ResetInterpreterCommand } from './configuration/interpreterSelector/commands/resetInterpreter'; import { SetInterpreterCommand } from './configuration/interpreterSelector/commands/setInterpreter'; @@ -29,6 +30,8 @@ import { InterpreterSelector } from './configuration/interpreterSelector/interpr import { PythonPathUpdaterService } from './configuration/pythonPathUpdaterService'; import { PythonPathUpdaterServiceFactory } from './configuration/pythonPathUpdaterServiceFactory'; import { + DEFAULT_COMPARISON, + ENV_TYPE_COMPARISON, IInterpreterComparer, IInterpreterSelector, IPythonPathUpdaterServiceFactory, @@ -91,7 +94,12 @@ export function registerInterpreterTypes(serviceManager: IServiceManager): void serviceManager.addSingleton(IShebangCodeLensProvider, ShebangCodeLensProvider); serviceManager.addSingleton(IInterpreterHelper, InterpreterHelper); - serviceManager.addSingleton(IInterpreterComparer, InterpreterComparer); + serviceManager.addSingleton(IInterpreterComparer, InterpreterComparer, DEFAULT_COMPARISON); + serviceManager.addSingleton( + IInterpreterComparer, + EnvironmentTypeComparer, + ENV_TYPE_COMPARISON, + ); serviceManager.addSingleton( IExtensionSingleActivationService, From f45210352399a6cf415076c394a3bb1b98ddbc56 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 23 Jun 2021 14:08:38 -0700 Subject: [PATCH 04/14] Add service registry tests --- .../interpreterSelector/interpreterSelector.ts | 7 +++---- src/client/interpreter/configuration/types.ts | 6 ++++-- src/client/interpreter/serviceRegistry.ts | 11 +++++++---- src/test/interpreters/serviceRegistry.unit.test.ts | 5 ++++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts index 38704531ff28..cffc5a69a77d 100644 --- a/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts +++ b/src/client/interpreter/configuration/interpreterSelector/interpreterSelector.ts @@ -10,11 +10,10 @@ import { IExperimentService, IPathUtils, Resource } from '../../../common/types' import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { IInterpreterService } from '../../contracts'; import { - DEFAULT_COMPARISON, - ENV_TYPE_COMPARISON, IInterpreterComparer, IInterpreterQuickPickItem, IInterpreterSelector, + InterpreterComparisonType, } from '../types'; @injectable() @@ -24,10 +23,10 @@ export class InterpreterSelector implements IInterpreterSelector { constructor( @inject(IInterpreterService) private readonly interpreterManager: IInterpreterService, @inject(IInterpreterComparer) - @named(DEFAULT_COMPARISON) + @named(InterpreterComparisonType.Default) private readonly interpreterComparer: IInterpreterComparer, @inject(IInterpreterComparer) - @named(ENV_TYPE_COMPARISON) + @named(InterpreterComparisonType.EnvType) private readonly envTypeComparer: IInterpreterComparer, @inject(IPathUtils) private readonly pathUtils: IPathUtils, @inject(IExperimentService) private readonly experimentService: IExperimentService, diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index 5af610bec183..9144e4538520 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -45,8 +45,10 @@ export interface IFindInterpreterQuickPickItem { alwaysShow: boolean; } -export const DEFAULT_COMPARISON = Symbol('DefaultEnvironmentComparison'); -export const ENV_TYPE_COMPARISON = Symbol('EnvironmentTypeComparison'); +export enum InterpreterComparisonType { + Default = 'defaultComparison', + EnvType = 'environmentTypeComparison', +} export const IInterpreterComparer = Symbol('IInterpreterComparer'); export interface IInterpreterComparer { diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index d61e2708dc98..7b95c9521ca9 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -30,10 +30,9 @@ import { InterpreterSelector } from './configuration/interpreterSelector/interpr import { PythonPathUpdaterService } from './configuration/pythonPathUpdaterService'; import { PythonPathUpdaterServiceFactory } from './configuration/pythonPathUpdaterServiceFactory'; import { - DEFAULT_COMPARISON, - ENV_TYPE_COMPARISON, IInterpreterComparer, IInterpreterSelector, + InterpreterComparisonType, IPythonPathUpdaterServiceFactory, IPythonPathUpdaterServiceManager, } from './configuration/types'; @@ -94,11 +93,15 @@ export function registerInterpreterTypes(serviceManager: IServiceManager): void serviceManager.addSingleton(IShebangCodeLensProvider, ShebangCodeLensProvider); serviceManager.addSingleton(IInterpreterHelper, InterpreterHelper); - serviceManager.addSingleton(IInterpreterComparer, InterpreterComparer, DEFAULT_COMPARISON); + serviceManager.addSingleton( + IInterpreterComparer, + InterpreterComparer, + InterpreterComparisonType.Default, + ); serviceManager.addSingleton( IInterpreterComparer, EnvironmentTypeComparer, - ENV_TYPE_COMPARISON, + InterpreterComparisonType.EnvType, ); serviceManager.addSingleton( diff --git a/src/test/interpreters/serviceRegistry.unit.test.ts b/src/test/interpreters/serviceRegistry.unit.test.ts index 1ffa57f914a7..2b60d161ab84 100644 --- a/src/test/interpreters/serviceRegistry.unit.test.ts +++ b/src/test/interpreters/serviceRegistry.unit.test.ts @@ -21,6 +21,7 @@ import { IInterpreterAutoSelectionService, IInterpreterAutoSelectionProxyService, } from '../../client/interpreter/autoSelection/types'; +import { EnvironmentTypeComparer } from '../../client/interpreter/configuration/environmentTypeComparer'; import { InterpreterComparer } from '../../client/interpreter/configuration/interpreterComparer'; import { ResetInterpreterCommand } from '../../client/interpreter/configuration/interpreterSelector/commands/resetInterpreter'; import { SetInterpreterCommand } from '../../client/interpreter/configuration/interpreterSelector/commands/setInterpreter'; @@ -31,6 +32,7 @@ import { PythonPathUpdaterServiceFactory } from '../../client/interpreter/config import { IInterpreterComparer, IInterpreterSelector, + InterpreterComparisonType, IPythonPathUpdaterServiceFactory, IPythonPathUpdaterServiceManager, } from '../../client/interpreter/configuration/types'; @@ -75,7 +77,8 @@ suite('Interpreters - Service Registry', () => { [IInterpreterSelector, InterpreterSelector], [IShebangCodeLensProvider, ShebangCodeLensProvider], [IInterpreterHelper, InterpreterHelper], - [IInterpreterComparer, InterpreterComparer], + [IInterpreterComparer, InterpreterComparer, InterpreterComparisonType.Default], + [IInterpreterComparer, EnvironmentTypeComparer, InterpreterComparisonType.EnvType], [IExtensionSingleActivationService, InterpreterLocatorProgressStatubarHandler], From ed65116d72d276d59fcac53b5ad8caa10fa92f05 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 23 Jun 2021 15:39:38 -0700 Subject: [PATCH 05/14] Add interpreter selector unit tests --- .../interpreterSelector.unit.test.ts | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts index 2561ecbb0299..13814f2e2eea 100644 --- a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts +++ b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import { SemVer } from 'semver'; import * as TypeMoq from 'typemoq'; import { Uri } from 'vscode'; -import { DeprecatePythonPath } from '../../../client/common/experiments/groups'; +import { DeprecatePythonPath, EnvironmentSorting } from '../../../client/common/experiments/groups'; import { PathUtils } from '../../../client/common/platform/pathUtils'; import { IFileSystem } from '../../../client/common/platform/types'; import { IExperimentService } from '../../../client/common/types'; @@ -48,7 +48,8 @@ class InterpreterQuickPickItem implements IInterpreterQuickPickItem { suite('Interpreters - selector', () => { let interpreterService: TypeMoq.IMock; let fileSystem: TypeMoq.IMock; - let comparer: TypeMoq.IMock; + let oldComparer: TypeMoq.IMock; + let newComparer: TypeMoq.IMock; let experimentsManager: TypeMoq.IMock; const ignoreCache = false; class TestInterpreterSelector extends InterpreterSelector { @@ -65,23 +66,37 @@ suite('Interpreters - selector', () => { setup(() => { experimentsManager = TypeMoq.Mock.ofType(); experimentsManager.setup((e) => e.inExperimentSync(DeprecatePythonPath.experiment)).returns(() => false); - comparer = TypeMoq.Mock.ofType(); + oldComparer = TypeMoq.Mock.ofType(); + newComparer = TypeMoq.Mock.ofType(); interpreterService = TypeMoq.Mock.ofType(); fileSystem = TypeMoq.Mock.ofType(); fileSystem .setup((x) => x.arePathsSame(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString())) .returns((a: string, b: string) => a === b); - comparer.setup((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => 0); - selector = new TestInterpreterSelector(interpreterService.object, comparer.object, new PathUtils(false)); + oldComparer.setup((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => 0); + newComparer.setup((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => 0); + selector = new TestInterpreterSelector( + interpreterService.object, + oldComparer.object, + newComparer.object, + new PathUtils(false), + experimentsManager.object, + ); }); [true, false].forEach((isWindows) => { test(`Suggestions (${isWindows ? 'Windows' : 'Non-Windows'})`, async () => { + experimentsManager + .setup((e) => e.inExperiment(EnvironmentSorting.experiment)) + .returns(() => Promise.resolve(false)); + selector = new TestInterpreterSelector( interpreterService.object, - comparer.object, + oldComparer.object, + newComparer.object, new PathUtils(isWindows), + experimentsManager.object, ); const initial: PythonEnvironment[] = [ @@ -107,14 +122,14 @@ suite('Interpreters - selector', () => { new InterpreterQuickPickItem('4', 'c:/path4/path4'), ]; - assert.equal(actual.length, expected.length, 'Suggestion lengths are different.'); + assert.strictEqual(actual.length, expected.length, 'Suggestion lengths are different.'); for (let i = 0; i < expected.length; i += 1) { - assert.equal( + assert.strictEqual( actual[i].label, expected[i].label, `Suggestion label is different at ${i}: exected '${expected[i].label}', found '${actual[i].label}'.`, ); - assert.equal( + assert.strictEqual( actual[i].path, expected[i].path, `Suggestion path is different at ${i}: exected '${expected[i].path}', found '${actual[i].path}'.`, @@ -122,4 +137,50 @@ suite('Interpreters - selector', () => { } }); }); + + test('Should use the old comparison logic when not in the EnvironmentSorting experiment', async () => { + const environments: PythonEnvironment[] = [ + { displayName: '1', path: 'c:/path1/path1', envType: EnvironmentType.Unknown }, + { displayName: '2', path: 'c:/path1/path1', envType: EnvironmentType.Unknown }, + { displayName: '2', path: 'c:/path2/path2', envType: EnvironmentType.Unknown }, + { displayName: '2 (virtualenv)', path: 'c:/path2/path2', envType: EnvironmentType.VirtualEnv }, + { displayName: '3', path: 'c:/path2/path2', envType: EnvironmentType.Unknown }, + { displayName: '4', path: 'c:/path4/path4', envType: EnvironmentType.Conda }, + ].map((item) => ({ ...info, ...item })); + interpreterService + .setup((x) => x.getInterpreters(TypeMoq.It.isAny(), { onSuggestion: true, ignoreCache })) + .returns(() => new Promise((resolve) => resolve(environments))); + + experimentsManager + .setup((e) => e.inExperiment(EnvironmentSorting.experiment)) + .returns(() => Promise.resolve(false)); + + await selector.getSuggestions(undefined, ignoreCache); + + oldComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.atLeastOnce()); + newComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.never()); + }); + + test('Should use the new comparison logic when in the EnvironmentSorting experiment', async () => { + const environments: PythonEnvironment[] = [ + { displayName: '1', path: 'c:/path1/path1', envType: EnvironmentType.Unknown }, + { displayName: '2', path: 'c:/path1/path1', envType: EnvironmentType.Unknown }, + { displayName: '2', path: 'c:/path2/path2', envType: EnvironmentType.Unknown }, + { displayName: '2 (virtualenv)', path: 'c:/path2/path2', envType: EnvironmentType.VirtualEnv }, + { displayName: '3', path: 'c:/path2/path2', envType: EnvironmentType.Unknown }, + { displayName: '4', path: 'c:/path4/path4', envType: EnvironmentType.Conda }, + ].map((item) => ({ ...info, ...item })); + interpreterService + .setup((x) => x.getInterpreters(TypeMoq.It.isAny(), { onSuggestion: true, ignoreCache })) + .returns(() => new Promise((resolve) => resolve(environments))); + + experimentsManager + .setup((e) => e.inExperiment(EnvironmentSorting.experiment)) + .returns(() => Promise.resolve(true)); + + await selector.getSuggestions(undefined, ignoreCache); + + oldComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.never()); + newComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.atLeastOnce()); + }); }); From d660e41c57daa7827b4fb022819aa2b0a109e5d5 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Thu, 24 Jun 2021 10:01:17 -0700 Subject: [PATCH 06/14] Add comparison unit tests --- .../configuration/environmentTypeComparer.ts | 2 +- .../environmentTypeComparer.unit.test.ts | 227 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/test/configuration/environmentTypeComparer.unit.test.ts diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts index 4eb8d49b6b06..42bfbc5e70d2 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -170,7 +170,7 @@ function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: stri switch (envType) { case EnvironmentType.Venv: { - if (workspacePath.length > 0 && environment.path.startsWith(workspacePath)) { + if (workspacePath.length > 0 && environment.envPath?.startsWith(workspacePath)) { return EnvTypeHeuristic.Local; } return EnvTypeHeuristic.Global; diff --git a/src/test/configuration/environmentTypeComparer.unit.test.ts b/src/test/configuration/environmentTypeComparer.unit.test.ts new file mode 100644 index 000000000000..66699006dd60 --- /dev/null +++ b/src/test/configuration/environmentTypeComparer.unit.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { EnvironmentTypeComparer } from '../../client/interpreter/configuration/environmentTypeComparer'; +import { IInterpreterHelper } from '../../client/interpreter/contracts'; +import { EnvironmentType, PythonEnvironment } from '../../client/pythonEnvironments/info'; + +suite('Environment sorting', () => { + const workspacePath = path.join('path', 'to', 'workspace'); + let interpreterHelper: IInterpreterHelper; + let getActiveWorkspaceUriStub: sinon.SinonStub; + let getInterpreterTypeDisplayNameStub: sinon.SinonStub; + + setup(() => { + getActiveWorkspaceUriStub = sinon.stub().returns({ folderUri: { fsPath: workspacePath } }); + getInterpreterTypeDisplayNameStub = sinon.stub(); + + interpreterHelper = ({ + getActiveWorkspaceUri: getActiveWorkspaceUriStub, + getInterpreterTypeDisplayName: getInterpreterTypeDisplayNameStub, + } as unknown) as IInterpreterHelper; + }); + + teardown(() => { + sinon.restore(); + }); + + type ComparisonTestCaseType = { + title: string; + envA: PythonEnvironment; + envB: PythonEnvironment; + expected: number; + }; + + const testcases: ComparisonTestCaseType[] = [ + { + title: 'Local virtual environment should come first', + envA: { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.venv'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.System, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: -1, + }, + { + title: "Non-local virtual environment should not come first when there's a local env", + envA: { + envType: EnvironmentType.Venv, + envPath: path.join('path', 'to', 'other', 'workspace', '.venv'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.venv'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: "Conda environment should not come first when there's a local env", + envA: { + envType: EnvironmentType.Conda, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.venv'), + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Conda base environment should come after any other conda env', + envA: { + envType: EnvironmentType.Conda, + envName: 'base', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Conda, + envName: 'random-name', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Pipenv environment should come before any other conda env', + envA: { + envType: EnvironmentType.Conda, + envName: 'conda-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Pipenv, + envName: 'pipenv-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + + expected: 1, + }, + { + title: 'System environment should not come first when there are global envs', + envA: { + envType: EnvironmentType.System, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Poetry, + envName: 'poetry-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Pyenv environment should not come first when there are global envs', + envA: { + envType: EnvironmentType.Pyenv, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Pipenv, + envName: 'pipenv-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Global environment should not come first when there are global envs', + envA: { + envType: EnvironmentType.Global, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Poetry, + envName: 'poetry-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Windows Store environment should not come first when there are global envs', + envA: { + envType: EnvironmentType.WindowsStore, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.VirtualEnv, + envName: 'virtualenv-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'Unknown environment should not come first when there are global envs', + envA: { + envType: EnvironmentType.Unknown, + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Pipenv, + envName: 'pipenv-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: 'If 2 environments are of the same type, the most recent Python version comes first', + envA: { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.old-venv'), + version: { major: 3, minor: 7, patch: 5, raw: '3.7.5' }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Venv, + envPath: path.join(workspacePath, '.venv'), + version: { major: 3, minor: 10, patch: 2, raw: '3.10.2' }, + } as PythonEnvironment, + expected: 1, + }, + { + title: + "If 2 global environments have the same Python version and there's a Conda one, the Conda env should not come first", + envA: { + envType: EnvironmentType.Conda, + envName: 'conda-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Pipenv, + envName: 'pipenv-env', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + { + title: + 'If 2 global environments are of the same type and have the same Python version, they should be sorted by name', + envA: { + envType: EnvironmentType.Conda, + envName: 'conda-foo', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + envB: { + envType: EnvironmentType.Conda, + envName: 'conda-bar', + version: { major: 3, minor: 10, patch: 2 }, + } as PythonEnvironment, + expected: 1, + }, + ]; + + testcases.forEach(({ title, envA, envB, expected }) => { + test(title, () => { + const envTypeComparer = new EnvironmentTypeComparer(interpreterHelper); + const result = envTypeComparer.compare(envA, envB); + + assert.strictEqual(result, expected); + }); + }); +}); From 776b11f0cff166c1f514e59b7a607100ef4b61ce Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Thu, 24 Jun 2021 12:14:10 -0700 Subject: [PATCH 07/14] Add intepreter selector test --- .../interpreterSelector.unit.test.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts index 13814f2e2eea..8a43d9d5e898 100644 --- a/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts +++ b/src/test/configuration/interpreterSelector/interpreterSelector.unit.test.ts @@ -3,6 +3,7 @@ // eslint-disable-next-line max-classes-per-file import * as assert from 'assert'; +import * as path from 'path'; import { SemVer } from 'semver'; import * as TypeMoq from 'typemoq'; import { Uri } from 'vscode'; @@ -11,10 +12,12 @@ import { PathUtils } from '../../../client/common/platform/pathUtils'; import { IFileSystem } from '../../../client/common/platform/types'; import { IExperimentService } from '../../../client/common/types'; import { Architecture } from '../../../client/common/utils/platform'; +import { EnvironmentTypeComparer } from '../../../client/interpreter/configuration/environmentTypeComparer'; import { InterpreterSelector } from '../../../client/interpreter/configuration/interpreterSelector/interpreterSelector'; import { IInterpreterComparer, IInterpreterQuickPickItem } from '../../../client/interpreter/configuration/types'; -import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { IInterpreterHelper, IInterpreterService, WorkspacePythonPath } from '../../../client/interpreter/contracts'; import { EnvironmentType, PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { getOSType, OSType } from '../../common'; const info: PythonEnvironment = { architecture: Architecture.Unknown, @@ -183,4 +186,86 @@ suite('Interpreters - selector', () => { oldComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.never()); newComparer.verify((c) => c.compare(TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.atLeastOnce()); }); + + test('Should sort environments with local ones first when in the EnvironmentSorting experiment', async () => { + const workspacePath = path.join('path', 'to', 'workspace'); + + const environments: PythonEnvironment[] = [ + { + displayName: 'one', + envPath: path.join('path', 'to', 'another', 'workspace', '.venv'), + path: path.join('path', 'to', 'another', 'workspace', '.venv', 'bin', 'python'), + envType: EnvironmentType.Venv, + }, + { + displayName: 'two', + envPath: path.join(workspacePath, '.venv'), + path: path.join(workspacePath, '.venv', 'bin', 'python'), + envType: EnvironmentType.Venv, + }, + { + displayName: 'three', + path: path.join('a', 'global', 'env', 'python'), + envPath: path.join('a', 'global', 'env'), + envType: EnvironmentType.Global, + }, + { + displayName: 'four', + envPath: path.join('a', 'conda', 'environment'), + path: path.join('a', 'conda', 'environment'), + envName: 'conda-env', + envType: EnvironmentType.Conda, + }, + ].map((item) => ({ ...info, ...item })); + + interpreterService + .setup((x) => x.getInterpreters(TypeMoq.It.isAny(), { onSuggestion: true, ignoreCache })) + .returns(() => new Promise((resolve) => resolve(environments))); + + experimentsManager + .setup((e) => e.inExperiment(EnvironmentSorting.experiment)) + .returns(() => Promise.resolve(true)); + + const interpreterHelper = TypeMoq.Mock.ofType(); + interpreterHelper + .setup((i) => i.getActiveWorkspaceUri(TypeMoq.It.isAny())) + .returns(() => ({ folderUri: { fsPath: workspacePath } } as WorkspacePythonPath)); + + const environmentTypeComparer = new EnvironmentTypeComparer(interpreterHelper.object); + + selector = new TestInterpreterSelector( + interpreterService.object, + oldComparer.object, + environmentTypeComparer, + new PathUtils(getOSType() === OSType.Windows), + experimentsManager.object, + ); + + const result = await selector.getSuggestions(undefined, ignoreCache); + + const expected: InterpreterQuickPickItem[] = [ + new InterpreterQuickPickItem('two', path.join(workspacePath, '.venv', 'bin', 'python')), + new InterpreterQuickPickItem( + 'one', + path.join('path', 'to', 'another', 'workspace', '.venv', 'bin', 'python'), + ), + new InterpreterQuickPickItem('four', path.join('a', 'conda', 'environment')), + new InterpreterQuickPickItem('three', path.join('a', 'global', 'env', 'python')), + ]; + + assert.strictEqual(result.length, expected.length, 'Suggestion lengths are different.'); + + for (let i = 0; i < expected.length; i += 1) { + assert.strictEqual( + result[i].label, + expected[i].label, + `Suggestion label is different at ${i}: exected '${expected[i].label}', found '${result[i].label}'.`, + ); + assert.strictEqual( + result[i].path, + expected[i].path, + `Suggestion path is different at ${i}: exected '${expected[i].path}', found '${result[i].path}'.`, + ); + } + }); }); From ef7f9c35efa297e303fd92b6fa54ade1c5def5fb Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Thu, 24 Jun 2021 12:16:25 -0700 Subject: [PATCH 08/14] News file --- news/1 Enhancements/16520.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/1 Enhancements/16520.md diff --git a/news/1 Enhancements/16520.md b/news/1 Enhancements/16520.md new file mode 100644 index 000000000000..98805c794fe9 --- /dev/null +++ b/news/1 Enhancements/16520.md @@ -0,0 +1 @@ +Sort environments in the selection quickpick by assumed usefulness. From eb9e895183b250657415e57c88849578f2daabcc Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Thu, 24 Jun 2021 12:19:31 -0700 Subject: [PATCH 09/14] Adjust comments --- .../configuration/environmentTypeComparer.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts index 42bfbc5e70d2..6e224302d415 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -9,10 +9,10 @@ import { IInterpreterHelper } from '../contracts'; import { IInterpreterComparer } from './types'; /* - * Order: - * - Local environments (i.e `.venv`); - * - Global environments (pipenv, conda), with conda environments at a lower priority, and "base" being last; - * - Globally-installed interpreters (`/usr/bin/python3`). + * Enum description: + * - Local environments (.venv); + * - Global environments (pipenv, conda); + * - Globally-installed interpreters (/usr/bin/python3, Windows Store). */ enum EnvTypeHeuristic { Local = 1, @@ -33,9 +33,9 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { * Return 0 if both environments are equal, -1 if a should be closer to the beginning of the list, or 1 if a comes after b. * * The comparison guidelines are: - * 1. Local environments first (i.e `.venv`); - * 2. Global environments next (conda); - * 3. Globally-installed interpreters (`/usr/bin/python3`). + * 1. Local environments first (.venv); + * 2. Global environments next (pipenv, conda), with conda environments at a lower priority, and "base" being last; + * 3. Globally-installed interpreters (/usr/bin/python3, Windows Store). * * Always sort with newest version of Python first within each subgroup. */ @@ -150,7 +150,7 @@ function comparePythonVersion(a: PythonVersion | undefined, b: PythonVersion | u } /** - * Compare 2 environment types, return 0 if they are the same, -1 if a comes before b, 1 otherwise. + * Compare 2 environment types: return 0 if they are the same, -1 if a comes before b, 1 otherwise. */ function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, workspacePath: string): number { const aHeuristic = getEnvTypeHeuristic(a, workspacePath); @@ -160,10 +160,7 @@ function compareEnvironmentType(a: PythonEnvironment, b: PythonEnvironment, work } /** - * Returns a heuristic value depending on the environment type: - * - 1: Local environments - * - 2: Global environments - * - 3: Global interpreters + * Return a heuristic value depending on the environment type. */ function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: string): EnvTypeHeuristic { const { envType } = environment; @@ -182,7 +179,7 @@ function getEnvTypeHeuristic(environment: PythonEnvironment, workspacePath: stri case EnvironmentType.Poetry: return EnvTypeHeuristic.Global; // The default case covers global environments. - // For now this includes: pyenv, Windows Store and everything under "Global", "System" and "Unknown". + // For now this includes: pyenv, Windows Store, Global, System and Unknown environment types. default: return EnvTypeHeuristic.GlobalInterpreters; } From c6a72759171f191e63f2682f2c1dc36d8b8a4ab9 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Thu, 24 Jun 2021 12:27:49 -0700 Subject: [PATCH 10/14] Reuse getSortName --- .../configuration/environmentTypeComparer.ts | 72 ++++++++++--------- .../configuration/interpreterComparer.ts | 40 +---------- 2 files changed, 40 insertions(+), 72 deletions(-) diff --git a/src/client/interpreter/configuration/environmentTypeComparer.ts b/src/client/interpreter/configuration/environmentTypeComparer.ts index 6e224302d415..2250d476f9e9 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -47,7 +47,7 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { } // Check Python version. - const versionComparison = comparePythonVersion(a.version, b.version); + const versionComparison = comparePythonVersionDescending(a.version, b.version); if (versionComparison !== 0) { return versionComparison; } @@ -67,49 +67,51 @@ export class EnvironmentTypeComparer implements IInterpreterComparer { } // Check alphabetical order (same way as the InterpreterComparer class). - const nameA = this.getSortName(a); - const nameB = this.getSortName(b); + const nameA = getSortName(a, this.interpreterHelper); + const nameB = getSortName(b, this.interpreterHelper); if (nameA === nameB) { return 0; } return nameA > nameB ? 1 : -1; } +} - private getSortName(info: PythonEnvironment): string { - const sortNameParts: string[] = []; - const envSuffixParts: string[] = []; - - // Sort order for interpreters is: - // * Version - // * Architecture - // * Interpreter Type - // * Environment name - if (info.version) { - sortNameParts.push(info.version.raw); - } - if (info.architecture) { - sortNameParts.push(getArchitectureDisplayName(info.architecture)); - } - if (info.companyDisplayName && info.companyDisplayName.length > 0) { - sortNameParts.push(info.companyDisplayName.trim()); - } else { - sortNameParts.push('Python'); - } +// This function is exported because the InterpreterComparer class uses the same logic. +// Once it gets removed as we ramp up #16520, we can restrict this function to this file. +export function getSortName(info: PythonEnvironment, interpreterHelper: IInterpreterHelper): string { + const sortNameParts: string[] = []; + const envSuffixParts: string[] = []; + + // Sort order for interpreters is: + // * Version + // * Architecture + // * Interpreter Type + // * Environment name + if (info.version) { + sortNameParts.push(info.version.raw); + } + if (info.architecture) { + sortNameParts.push(getArchitectureDisplayName(info.architecture)); + } + if (info.companyDisplayName && info.companyDisplayName.length > 0) { + sortNameParts.push(info.companyDisplayName.trim()); + } else { + sortNameParts.push('Python'); + } - if (info.envType) { - const name = this.interpreterHelper.getInterpreterTypeDisplayName(info.envType); - if (name) { - envSuffixParts.push(name); - } - } - if (info.envName && info.envName.length > 0) { - envSuffixParts.push(info.envName); + if (info.envType) { + const name = interpreterHelper.getInterpreterTypeDisplayName(info.envType); + if (name) { + envSuffixParts.push(name); } - - const envSuffix = envSuffixParts.length === 0 ? '' : `(${envSuffixParts.join(': ')})`; - return `${sortNameParts.join(' ')} ${envSuffix}`.trim(); } + if (info.envName && info.envName.length > 0) { + envSuffixParts.push(info.envName); + } + + const envSuffix = envSuffixParts.length === 0 ? '' : `(${envSuffixParts.join(': ')})`; + return `${sortNameParts.join(' ')} ${envSuffix}`.trim(); } function isCondaEnvironment(environment: PythonEnvironment): boolean { @@ -123,7 +125,7 @@ function isBaseCondaEnvironment(environment: PythonEnvironment): boolean { /** * Compare 2 Python versions in decending order, most recent one comes first. */ -function comparePythonVersion(a: PythonVersion | undefined, b: PythonVersion | undefined): number { +function comparePythonVersionDescending(a: PythonVersion | undefined, b: PythonVersion | undefined): number { if (!a) { return 1; } diff --git a/src/client/interpreter/configuration/interpreterComparer.ts b/src/client/interpreter/configuration/interpreterComparer.ts index b309050590a3..3d577602d92d 100644 --- a/src/client/interpreter/configuration/interpreterComparer.ts +++ b/src/client/interpreter/configuration/interpreterComparer.ts @@ -4,54 +4,20 @@ 'use strict'; import { inject, injectable } from 'inversify'; -import { getArchitectureDisplayName } from '../../common/platform/registry'; import { PythonEnvironment } from '../../pythonEnvironments/info'; import { IInterpreterHelper } from '../contracts'; +import { getSortName } from './environmentTypeComparer'; import { IInterpreterComparer } from './types'; @injectable() export class InterpreterComparer implements IInterpreterComparer { constructor(@inject(IInterpreterHelper) private readonly interpreterHelper: IInterpreterHelper) {} public compare(a: PythonEnvironment, b: PythonEnvironment): number { - const nameA = this.getSortName(a); - const nameB = this.getSortName(b); + const nameA = getSortName(a, this.interpreterHelper); + const nameB = getSortName(b, this.interpreterHelper); if (nameA === nameB) { return 0; } return nameA > nameB ? 1 : -1; } - private getSortName(info: PythonEnvironment): string { - const sortNameParts: string[] = []; - const envSuffixParts: string[] = []; - - // Sort order for interpreters is: - // * Version - // * Architecture - // * Interpreter Type - // * Environment name - if (info.version) { - sortNameParts.push(info.version.raw); - } - if (info.architecture) { - sortNameParts.push(getArchitectureDisplayName(info.architecture)); - } - if (info.companyDisplayName && info.companyDisplayName.length > 0) { - sortNameParts.push(info.companyDisplayName.trim()); - } else { - sortNameParts.push('Python'); - } - - if (info.envType) { - const name = this.interpreterHelper.getInterpreterTypeDisplayName(info.envType); - if (name) { - envSuffixParts.push(name); - } - } - if (info.envName && info.envName.length > 0) { - envSuffixParts.push(info.envName); - } - - const envSuffix = envSuffixParts.length === 0 ? '' : `(${envSuffixParts.join(': ')})`; - return `${sortNameParts.join(' ')} ${envSuffix}`.trim(); - } } From 878cfbabedb3c4dbda40e9f119cf56b022ae358f Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 7 Jul 2021 12:02:26 -0700 Subject: [PATCH 11/14] Add new auto-selection logic --- src/client/interpreter/autoSelection/index.ts | 72 +++++++++++++++++-- .../configuration/environmentTypeComparer.ts | 4 +- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/client/interpreter/autoSelection/index.ts b/src/client/interpreter/autoSelection/index.ts index 5bc13628e3b5..ce26b8a86548 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,27 @@ 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 +237,46 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio } return undefined; } + + private async autoselectInterpreterWithRules(resource: Resource): Promise { + 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(); + } + + /** + * 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 2250d476f9e9..4715e4b27418 100644 --- a/src/client/interpreter/configuration/environmentTypeComparer.ts +++ b/src/client/interpreter/configuration/environmentTypeComparer.ts @@ -14,7 +14,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, @@ -164,7 +164,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; switch (envType) { From 778454f2f60cb85395bc0ba94c14f8e1112d6a8e Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Wed, 7 Jul 2021 15:53:33 -0700 Subject: [PATCH 12/14] Add tests for getEnvTypeHeuristic --- .../environmentTypeComparer.unit.test.ts | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/test/configuration/environmentTypeComparer.unit.test.ts b/src/test/configuration/environmentTypeComparer.unit.test.ts index 66699006dd60..72209a18c447 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,70 @@ suite('Environment sorting', () => { }); }); }); + +suite('getEnvTypeHeuristic tests', () => { + const workspacePath = path.join('path', 'to', 'workspace'); + + // case EnvironmentType.Conda: + // case EnvironmentType.VirtualEnv: + // case EnvironmentType.VirtualEnvWrapper: + // case EnvironmentType.Pipenv: + // case EnvironmentType.Poetry: + + 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); + }); + }); +}); From f8393aab3403410d19e278e78e80aa9915afe832 Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Fri, 9 Jul 2021 17:01:55 -0700 Subject: [PATCH 13/14] Move persistent store initialization back out --- src/client/interpreter/autoSelection/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client/interpreter/autoSelection/index.ts b/src/client/interpreter/autoSelection/index.ts index ce26b8a86548..1340c0c4daee 100644 --- a/src/client/interpreter/autoSelection/index.ts +++ b/src/client/interpreter/autoSelection/index.ts @@ -124,6 +124,9 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio const deferred = createDeferred(); this.autoSelectedWorkspacePromises.set(key, deferred); + await this.initializeStore(resource); + await this.clearWorkspaceStoreIfInvalid(resource); + if (await this.experimentService.inExperiment(EnvironmentSorting.experiment)) { await this.autoselectInterpreterWithLocators(resource); } else { @@ -239,8 +242,6 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio } private async autoselectInterpreterWithRules(resource: Resource): Promise { - await this.initializeStore(resource); - await this.clearWorkspaceStoreIfInvalid(resource); await this.userDefinedInterpreter.autoSelectInterpreter(resource, this); this.didAutoSelectedInterpreterEmitter.fire(); From 82f15aea35e4bab67b8251e6d0e6c8a06adf90bf Mon Sep 17 00:00:00 2001 From: Kim-Adeline Miguel Date: Fri, 9 Jul 2021 17:02:11 -0700 Subject: [PATCH 14/14] Update tests --- .../autoSelection/index.unit.test.ts | 183 ++++++++++++++---- 1 file changed, 150 insertions(+), 33 deletions(-) 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);