diff --git a/src/client/pythonEnvironments/base/info/env.ts b/src/client/pythonEnvironments/base/info/env.ts new file mode 100644 index 000000000000..5d88a27c0710 --- /dev/null +++ b/src/client/pythonEnvironments/base/info/env.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { cloneDeep } from 'lodash'; +import * as path from 'path'; +import { + FileInfo, + PythonDistroInfo, + PythonEnvInfo, PythonEnvKind, PythonVersion, +} from '.'; +import { Architecture } from '../../../common/utils/platform'; +import { arePathsSame } from '../../common/externalDependencies'; +import { areEqualVersions, areEquivalentVersions } from './pythonVersion'; + +/** + * Checks if two environments are same. + * @param {string | PythonEnvInfo} left: environment to compare. + * @param {string | PythonEnvInfo} right: environment to compare. + * @param {boolean} allowPartialMatch: allow partial matches of properties when comparing. + * + * Remarks: The current comparison assumes that if the path to the executables are the same + * then it is the same environment. Additionally, if the paths are not same but executables + * are in the same directory and the version of python is the same than we can assume it + * to be same environment. This later case is needed for comparing windows store python, + * where multiple versions of python executables are all put in the same directory. + */ +export function areSameEnvironment( + left: string | PythonEnvInfo, + right: string | PythonEnvInfo, + allowPartialMatch?: boolean, +): boolean { + const leftFilename = typeof left === 'string' ? left : left.executable.filename; + const rightFilename = typeof right === 'string' ? right : right.executable.filename; + + if (arePathsSame(leftFilename, rightFilename)) { + return true; + } + + if (arePathsSame(path.dirname(leftFilename), path.dirname(rightFilename))) { + const leftVersion = typeof left === 'string' ? undefined : left.version; + const rightVersion = typeof right === 'string' ? undefined : right.version; + if (leftVersion && rightVersion) { + if ( + areEqualVersions(leftVersion, rightVersion) + || (allowPartialMatch && areEquivalentVersions(leftVersion, rightVersion)) + ) { + return true; + } + } + } + return false; +} + +/** + * Returns a heuristic value on how much information is available in the given version object. + * @param {PythonVersion} version version object to generate heuristic from. + * @returns A heuristic value indicating the amount of info available in the object + * weighted by most important to least important fields. + * Wn > Wn-1 + Wn-2 + ... W0 + */ +function getPythonVersionInfoHeuristic(version:PythonVersion): number { + let infoLevel = 0; + if (version.major > 0) { + infoLevel += 20; // W4 + } + + if (version.minor >= 0) { + infoLevel += 10; // W3 + } + + if (version.micro >= 0) { + infoLevel += 5; // W2 + } + + if (version.release.level) { + infoLevel += 3; // W1 + } + + if (version.release.serial || version.sysVersion) { + infoLevel += 1; // W0 + } + + return infoLevel; +} + +/** + * Returns a heuristic value on how much information is available in the given executable object. + * @param {FileInfo} executable executable object to generate heuristic from. + * @returns A heuristic value indicating the amount of info available in the object + * weighted by most important to least important fields. + * Wn > Wn-1 + Wn-2 + ... W0 + */ +function getFileInfoHeuristic(file:FileInfo): number { + let infoLevel = 0; + if (file.filename.length > 0) { + infoLevel += 5; // W2 + } + + if (file.mtime) { + infoLevel += 2; // W1 + } + + if (file.ctime) { + infoLevel += 1; // W0 + } + + return infoLevel; +} + +/** + * Returns a heuristic value on how much information is available in the given distro object. + * @param {PythonDistroInfo} distro distro object to generate heuristic from. + * @returns A heuristic value indicating the amount of info available in the object + * weighted by most important to least important fields. + * Wn > Wn-1 + Wn-2 + ... W0 + */ +function getDistroInfoHeuristic(distro:PythonDistroInfo):number { + let infoLevel = 0; + if (distro.org.length > 0) { + infoLevel += 20; // W3 + } + + if (distro.defaultDisplayName) { + infoLevel += 10; // W2 + } + + if (distro.binDir) { + infoLevel += 5; // W1 + } + + if (distro.version) { + infoLevel += 2; + } + + return infoLevel; +} + +/** + * Gets a prioritized list of environment types for identification. + * @returns {PythonEnvKind[]} : List of environments ordered by identification priority + * + * Remarks: This is the order of detection based on how the various distributions and tools + * configure the environment, and the fall back for identification. + * Top level we have the following environment types, since they leave a unique signature + * in the environment or * use a unique path for the environments they create. + * 1. Conda + * 2. Windows Store + * 3. PipEnv + * 4. Pyenv + * 5. Poetry + * + * Next level we have the following virtual environment tools. The are here because they + * are consumed by the tools above, and can also be used independently. + * 1. venv + * 2. virtualenvwrapper + * 3. virtualenv + * + * Last category is globally installed python, or system python. + */ +export function getPrioritizedEnvironmentKind(): PythonEnvKind[] { + return [ + PythonEnvKind.CondaBase, + PythonEnvKind.Conda, + PythonEnvKind.WindowsStore, + PythonEnvKind.Pipenv, + PythonEnvKind.Pyenv, + PythonEnvKind.Poetry, + PythonEnvKind.Venv, + PythonEnvKind.VirtualEnvWrapper, + PythonEnvKind.VirtualEnv, + PythonEnvKind.OtherVirtual, + PythonEnvKind.OtherGlobal, + PythonEnvKind.MacDefault, + PythonEnvKind.System, + PythonEnvKind.Custom, + PythonEnvKind.Unknown, + ]; +} + +/** + * Selects an environment based on the environment selection priority. This should + * match the priority in the environment identifier. + */ +export function sortEnvInfoByPriority(...envs: PythonEnvInfo[]): PythonEnvInfo[] { + // tslint:disable-next-line: no-suspicious-comment + // TODO: When we consolidate the PythonEnvKind and EnvironmentType we should have + // one location where we define priority and + const envKindByPriority:PythonEnvKind[] = getPrioritizedEnvironmentKind(); + return envs.sort( + (a:PythonEnvInfo, b:PythonEnvInfo) => envKindByPriority.indexOf(a.kind) - envKindByPriority.indexOf(b.kind), + ); +} + +/** + * Merges properties of the `target` environment and `other` environment and returns the merged environment. + * if the value in the `target` environment is not defined or has less information. This does not mutate + * the `target` instead it returns a new object that contains the merged results. + * @param {PythonEnvInfo} target : Properties of this object are favored. + * @param {PythonEnvInfo} other : Properties of this object are used to fill the gaps in the merged result. + */ +export function mergeEnvironments(target: PythonEnvInfo, other: PythonEnvInfo): PythonEnvInfo { + const merged = cloneDeep(target); + + const version = cloneDeep( + getPythonVersionInfoHeuristic(target.version) > getPythonVersionInfoHeuristic(other.version) + ? target.version : other.version, + ); + + const executable = cloneDeep( + getFileInfoHeuristic(target.executable) > getFileInfoHeuristic(other.executable) + ? target.executable : other.executable, + ); + executable.sysPrefix = target.executable.sysPrefix ?? other.executable.sysPrefix; + + const distro = cloneDeep( + getDistroInfoHeuristic(target.distro) > getDistroInfoHeuristic(other.distro) + ? target.distro : other.distro, + ); + + merged.arch = merged.arch === Architecture.Unknown ? other.arch : target.arch; + merged.defaultDisplayName = merged.defaultDisplayName ?? other.defaultDisplayName; + merged.distro = distro; + merged.executable = executable; + + // No need to check this just use preferred kind. Since the first thing we do is figure out the + // preferred env based on kind. + merged.kind = target.kind; + + merged.location = merged.location ?? other.location; + merged.name = merged.name ?? other.name; + merged.searchLocation = merged.searchLocation ?? other.searchLocation; + merged.version = version; + + return merged; +} diff --git a/src/client/pythonEnvironments/base/info/index.ts b/src/client/pythonEnvironments/base/info/index.ts index c24cf8dc2aa3..5298a3d73f9e 100644 --- a/src/client/pythonEnvironments/base/info/index.ts +++ b/src/client/pythonEnvironments/base/info/index.ts @@ -4,7 +4,6 @@ import { Uri } from 'vscode'; import { Architecture } from '../../../common/utils/platform'; import { BasicVersionInfo, VersionInfo } from '../../../common/utils/version'; -import { arePathsSame } from '../../common/externalDependencies'; /** * IDs for the various supported Python environments. @@ -17,26 +16,34 @@ export enum PythonEnvKind { WindowsStore = 'global-windows-store', Pyenv = 'global-pyenv', CondaBase = 'global-conda-base', + Poetry = 'global-poetry', Custom = 'global-custom', OtherGlobal = 'global-other', // "virtual" Venv = 'virt-venv', VirtualEnv = 'virt-virtualenv', + VirtualEnvWrapper = 'virt-virtualenvwrapper', Pipenv = 'virt-pipenv', Conda = 'virt-conda', OtherVirtual = 'virt-other' } /** - * Information about a Python binary/executable. + * Information about a file. */ -export type PythonExecutableInfo = { +export type FileInfo = { filename: string; - sysPrefix: string; ctime: number; mtime: number; }; +/** + * Information about a Python binary/executable. + */ +export type PythonExecutableInfo = FileInfo & { + sysPrefix: string; +}; + /** * A (system-global) unique ID for a single Python environment. */ @@ -144,31 +151,3 @@ export type PythonEnvInfo = _PythonEnvInfo & { defaultDisplayName?: string; searchLocation?: Uri; }; - -/** - * Determine if the given infos correspond to the same env. - * - * @param environment1 - one of the two envs to compare - * @param environment2 - one of the two envs to compare - */ -export function areSameEnvironment( - environment1: PythonEnvInfo | string, - environment2: PythonEnvInfo | string, -): boolean { - let path1: string; - let path2: string; - if (typeof environment1 === 'string') { - path1 = environment1; - } else { - path1 = environment1.executable.filename; - } - if (typeof environment2 === 'string') { - path2 = environment2; - } else { - path2 = environment2.executable.filename; - } - if (arePathsSame(path1, path2)) { - return true; - } - return false; -} diff --git a/src/client/pythonEnvironments/base/info/pythonVersion.ts b/src/client/pythonEnvironments/base/info/pythonVersion.ts index 58248f2c1789..40e663c6231e 100644 --- a/src/client/pythonEnvironments/base/info/pythonVersion.ts +++ b/src/client/pythonEnvironments/base/info/pythonVersion.ts @@ -33,3 +33,34 @@ export function parseVersion(versionStr: string): PythonVersion { } return version; } + +/** + * Checks if all the fields in the version object match. + * @param {PythonVersion} left + * @param {PythonVersion} right + * @returns {boolean} + */ +export function areEqualVersions(left: PythonVersion, right:PythonVersion): boolean { + return left === right; +} + +/** + * Checks if major and minor version fields match. True here means that the python ABI is the + * same, but the micro version could be different. But for the purpose this is being used + * it does not matter. + * @param {PythonVersion} left + * @param {PythonVersion} right + * @returns {boolean} + */ +export function areEquivalentVersions(left: PythonVersion, right:PythonVersion): boolean { + if (left.major === 2 && right.major === 2) { + // We are going to assume that if the major version is 2 then the version is 2.7 + return true; + } + + // In the case of 3.* if major and minor match we assume that they are equivalent versions + return ( + left.major === right.major + && left.minor === right.minor + ); +} diff --git a/src/client/pythonEnvironments/base/locators/composite/environmentsReducer.ts b/src/client/pythonEnvironments/base/locators/composite/environmentsReducer.ts index ba00eac2a546..617160c05b11 100644 --- a/src/client/pythonEnvironments/base/locators/composite/environmentsReducer.ts +++ b/src/client/pythonEnvironments/base/locators/composite/environmentsReducer.ts @@ -5,7 +5,8 @@ import { cloneDeep, isEqual } from 'lodash'; import { Event, EventEmitter } from 'vscode'; import { traceVerbose } from '../../../../common/logger'; import { createDeferred } from '../../../../common/utils/async'; -import { areSameEnvironment, PythonEnvInfo, PythonEnvKind } from '../../info'; +import { PythonEnvInfo, PythonEnvKind } from '../../info'; +import { areSameEnvironment } from '../../info/env'; import { ILocator, IPythonEnvsIterator, PythonEnvUpdatedEvent, PythonLocatorQuery, } from '../../locator'; diff --git a/src/client/pythonEnvironments/base/locators/composite/environmentsResolver.ts b/src/client/pythonEnvironments/base/locators/composite/environmentsResolver.ts index 347785f73660..83ea21e726e5 100644 --- a/src/client/pythonEnvironments/base/locators/composite/environmentsResolver.ts +++ b/src/client/pythonEnvironments/base/locators/composite/environmentsResolver.ts @@ -5,7 +5,8 @@ import { cloneDeep } from 'lodash'; import { Event, EventEmitter } from 'vscode'; import { traceVerbose } from '../../../../common/logger'; import { IEnvironmentInfoService } from '../../../info/environmentInfoService'; -import { areSameEnvironment, PythonEnvInfo } from '../../info'; +import { PythonEnvInfo } from '../../info'; +import { areSameEnvironment } from '../../info/env'; import { InterpreterInformation } from '../../info/interpreter'; import { ILocator, IPythonEnvsIterator, PythonEnvUpdatedEvent, PythonLocatorQuery, diff --git a/src/client/pythonEnvironments/common/environmentIdentifier.ts b/src/client/pythonEnvironments/common/environmentIdentifier.ts index b56c84e2b73e..3340cb01bdaf 100644 --- a/src/client/pythonEnvironments/common/environmentIdentifier.ts +++ b/src/client/pythonEnvironments/common/environmentIdentifier.ts @@ -11,9 +11,8 @@ import { isWindowsStoreEnvironment } from '../discovery/locators/services/window import { EnvironmentType } from '../info'; /** - * Returns environment type. - * @param {string} interpreterPath : Absolute path to the python interpreter binary. - * @returns {EnvironmentType} + * Gets a prioritized list of environment types for identification. + * @deprecated * * Remarks: This is the order of detection based on how the various distributions and tools * configure the environment, and the fall back for identification. @@ -33,36 +32,56 @@ import { EnvironmentType } from '../info'; * * Last category is globally installed python, or system python. */ -export async function identifyEnvironment(interpreterPath: string): Promise { - if (await isCondaEnvironment(interpreterPath)) { - return EnvironmentType.Conda; - } - - if (await isWindowsStoreEnvironment(interpreterPath)) { - return EnvironmentType.WindowsStore; - } - - if (await isPipenvEnvironment(interpreterPath)) { - return EnvironmentType.Pipenv; - } +export function getPrioritizedEnvironmentType():EnvironmentType[] { + return [ + EnvironmentType.Conda, + EnvironmentType.WindowsStore, + EnvironmentType.Pipenv, + EnvironmentType.Pyenv, + EnvironmentType.Poetry, + EnvironmentType.Venv, + EnvironmentType.VirtualEnvWrapper, + EnvironmentType.VirtualEnv, + EnvironmentType.Global, + EnvironmentType.System, + EnvironmentType.Unknown, + ]; +} - if (await isPyenvEnvironment(interpreterPath)) { - return EnvironmentType.Pyenv; - } +function getIdentifiers(): Map Promise> { + const notImplemented = () => Promise.resolve(false); + const defaultTrue = () => Promise.resolve(true); + const identifier: Map Promise> = new Map(); + Object.keys(EnvironmentType).forEach((k:string) => { + identifier.set(k as EnvironmentType, notImplemented); + }); - if (await isVenvEnvironment(interpreterPath)) { - return EnvironmentType.Venv; - } - - if (await isVirtualenvwrapperEnvironment(interpreterPath)) { - return EnvironmentType.VirtualEnvWrapper; - } + identifier.set(EnvironmentType.Conda, isCondaEnvironment); + identifier.set(EnvironmentType.WindowsStore, isWindowsStoreEnvironment); + identifier.set(EnvironmentType.Pipenv, isPipenvEnvironment); + identifier.set(EnvironmentType.Pyenv, isPyenvEnvironment); + identifier.set(EnvironmentType.Venv, isVenvEnvironment); + identifier.set(EnvironmentType.VirtualEnvWrapper, isVirtualenvwrapperEnvironment); + identifier.set(EnvironmentType.VirtualEnv, isVirtualenvEnvironment); + identifier.set(EnvironmentType.Unknown, defaultTrue); + return identifier; +} - if (await isVirtualenvEnvironment(interpreterPath)) { - return EnvironmentType.VirtualEnv; +/** + * Returns environment type. + * @param {string} interpreterPath : Absolute path to the python interpreter binary. + * @returns {EnvironmentType} + */ +export async function identifyEnvironment(interpreterPath: string): Promise { + const identifiers = getIdentifiers(); + const prioritizedEnvTypes = getPrioritizedEnvironmentType(); + // eslint-disable-next-line no-restricted-syntax + for (const e of prioritizedEnvTypes) { + const identifier = identifiers.get(e); + // eslint-disable-next-line no-await-in-loop + if (identifier && await identifier(interpreterPath)) { + return e; + } } - - // additional identifiers go here - return EnvironmentType.Unknown; } diff --git a/src/client/pythonEnvironments/info/index.ts b/src/client/pythonEnvironments/info/index.ts index ba0c90b5ab68..51fc84285638 100644 --- a/src/client/pythonEnvironments/info/index.ts +++ b/src/client/pythonEnvironments/info/index.ts @@ -92,7 +92,7 @@ export function normalizeEnvironment(environment: PartialPythonEnvironment): voi /** * Convert the Python environment type to a user-facing name. */ -export function getEnvironmentTypeName(environmentType: EnvironmentType) { +export function getEnvironmentTypeName(environmentType: EnvironmentType): string { switch (environmentType) { case EnvironmentType.Conda: { return 'conda'; @@ -109,6 +109,12 @@ export function getEnvironmentTypeName(environmentType: EnvironmentType) { case EnvironmentType.VirtualEnv: { return 'virtualenv'; } + case EnvironmentType.WindowsStore: { + return 'windows store'; + } + case EnvironmentType.Poetry: { + return 'poetry'; + } default: { return ''; } @@ -121,7 +127,7 @@ export function getEnvironmentTypeName(environmentType: EnvironmentType) { * @param environment1 - one of the two envs to compare * @param environment2 - one of the two envs to compare */ -export function areSameEnvironment( +export function areSamePartialEnvironment( environment1: PartialPythonEnvironment | undefined, environment2: PartialPythonEnvironment | undefined, fs: IFileSystem, @@ -185,7 +191,7 @@ export function mergeEnvironments( fs: IFileSystem, ): PartialPythonEnvironment[] { return environments.reduce((accumulator, current) => { - const existingItem = accumulator.find((item) => areSameEnvironment(current, item, fs)); + const existingItem = accumulator.find((item) => areSamePartialEnvironment(current, item, fs)); if (!existingItem) { const copied: PartialPythonEnvironment = { ...current }; normalizeEnvironment(copied);