From aeb80cf9203f314c306df7200c3e7d926dce0dab Mon Sep 17 00:00:00 2001 From: Kartik Raj Date: Mon, 26 Sep 2022 16:40:23 -0700 Subject: [PATCH] Finalizing design of proposed API for python environments (#19841) Closes https://github.com/microsoft/vscode-python/issues/19101 closes #18973 Co-authored-by: Karthik Nadig --- .eslintignore | 1 - package-lock.json | 18 +- package.json | 4 +- src/client/apiTypes.ts | 136 ---- src/client/common/application/extensions.ts | 69 +- src/client/common/types.ts | 5 + src/client/deprecatedProposedApi.ts | 161 +++++ src/client/deprecatedProposedApiTypes.ts | 142 ++++ src/client/extension.ts | 5 +- src/client/interpreter/interpreterService.ts | 4 +- src/client/proposedApi.ts | 386 ++++++++--- src/client/proposedApiTypes.ts | 262 ++++++++ src/client/pythonEnvironments/api.ts | 5 + src/client/pythonEnvironments/base/locator.ts | 1 + .../locators/composite/envsCollectionCache.ts | 12 - .../composite/envsCollectionService.ts | 3 + src/client/telemetry/constants.ts | 1 + src/client/telemetry/index.ts | 25 + .../interpreterService.unit.test.ts | 8 +- src/test/proposedApi.unit.test.ts | 627 +++++++++--------- .../envsCollectionService.unit.test.ts | 26 - 21 files changed, 1309 insertions(+), 592 deletions(-) create mode 100644 src/client/deprecatedProposedApi.ts create mode 100644 src/client/deprecatedProposedApiTypes.ts create mode 100644 src/client/proposedApiTypes.ts diff --git a/.eslintignore b/.eslintignore index 2f759e89c516..7ba146c7d8e4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -246,7 +246,6 @@ src/client/common/application/languageService.ts src/client/common/application/clipboard.ts src/client/common/application/workspace.ts src/client/common/application/debugSessionTelemetry.ts -src/client/common/application/extensions.ts src/client/common/application/documentManager.ts src/client/common/application/debugService.ts src/client/common/application/commands/reloadCommand.ts diff --git a/package-lock.json b/package-lock.json index be50eac6b1f9..8bd10ba6f104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "rxjs": "^6.5.4", "rxjs-compat": "^6.5.4", "semver": "^5.5.0", + "stack-trace": "0.0.10", "sudo-prompt": "^9.2.1", "tmp": "^0.0.33", "uint64be": "^3.0.0", @@ -65,6 +66,7 @@ "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^10.0.11", + "@types/stack-trace": "0.0.29", "@types/tmp": "^0.0.33", "@types/uuid": "^8.3.4", "@types/vscode": "~1.68.0", @@ -833,6 +835,12 @@ "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", "dev": true }, + "node_modules/@types/stack-trace": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.29.tgz", + "integrity": "sha512-TgfOX+mGY/NyNxJLIbDWrO9DjGoVSW9+aB8H2yy1fy32jsvxijhmyJI9fDFgvz3YP4lvJaq9DzdR/M1bOgVc9g==", + "dev": true + }, "node_modules/@types/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.0.33.tgz", @@ -12554,7 +12562,6 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", - "dev": true, "engines": { "node": "*" } @@ -15989,6 +15996,12 @@ "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", "dev": true }, + "@types/stack-trace": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.29.tgz", + "integrity": "sha512-TgfOX+mGY/NyNxJLIbDWrO9DjGoVSW9+aB8H2yy1fy32jsvxijhmyJI9fDFgvz3YP4lvJaq9DzdR/M1bOgVc9g==", + "dev": true + }, "@types/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.0.33.tgz", @@ -25224,8 +25237,7 @@ "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", - "dev": true + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" }, "static-extend": { "version": "0.1.2", diff --git a/package.json b/package.json index 1d7a09308eed..0f9f3e97cb2a 100644 --- a/package.json +++ b/package.json @@ -1816,6 +1816,7 @@ "request-progress": "^3.0.0", "rxjs": "^6.5.4", "rxjs-compat": "^6.5.4", + "stack-trace": "0.0.10", "semver": "^5.5.0", "sudo-prompt": "^9.2.1", "tmp": "^0.0.33", @@ -1852,11 +1853,12 @@ "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^10.0.11", + "@types/stack-trace": "0.0.29", "@types/tmp": "^0.0.33", "@types/uuid": "^8.3.4", "@types/vscode": "~1.68.0", - "@types/winreg": "^1.2.30", "@types/which": "^2.0.1", + "@types/winreg": "^1.2.30", "@types/xml2js": "^0.4.2", "@typescript-eslint/eslint-plugin": "^3.7.0", "@typescript-eslint/parser": "^3.7.0", diff --git a/src/client/apiTypes.ts b/src/client/apiTypes.ts index 6361a75edb48..a10fd2dccb96 100644 --- a/src/client/apiTypes.ts +++ b/src/client/apiTypes.ts @@ -4,8 +4,6 @@ import { Event, Uri } from 'vscode'; import { Resource } from './common/types'; import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types'; -import { EnvPathType, PythonEnvKind } from './pythonEnvironments/base/info'; -import { GetRefreshEnvironmentsOptions, ProgressNotificationEvent } from './pythonEnvironments/base/locator'; /* * Do not introduce any breaking changes to this API. @@ -88,137 +86,3 @@ export interface IExtensionApi { registerRemoteServerProvider(serverProvider: IJupyterUriProvider): void; }; } - -export interface EnvironmentDetailsOptions { - useCache: boolean; -} - -export interface EnvironmentDetails { - interpreterPath: string; - envFolderPath?: string; - version: string[]; - environmentType: PythonEnvKind[]; - metadata: Record; -} - -export interface EnvironmentsChangedParams { - /** - * Path to environment folder or path to interpreter that uniquely identifies an environment. - * Virtual environments lacking an interpreter are identified by environment folder paths, - * whereas other envs can be identified using interpreter path. - */ - path?: string; - type: 'add' | 'remove' | 'update' | 'clear-all'; -} - -export interface ActiveEnvironmentChangedParams { - /** - * Path to environment folder or path to interpreter that uniquely identifies an environment. - * Virtual environments lacking an interpreter are identified by environment folder paths, - * whereas other envs can be identified using interpreter path. - */ - path: string; - resource?: Uri; -} - -export interface IProposedExtensionAPI { - environment: { - /** - * An event that is emitted when execution details (for a resource) change. For instance, when interpreter configuration changes. - */ - readonly onDidChangeExecutionDetails: Event; - /** - * Returns all the details the consumer needs to execute code within the selected environment, - * corresponding to the specified resource taking into account any workspace-specific settings - * for the workspace to which this resource belongs. - * @param {Resource} [resource] A resource for which the setting is asked for. - * * When no resource is provided, the setting scoped to the first workspace folder is returned. - * * If no folder is present, it returns the global setting. - * @returns {({ execCommand: string[] | undefined })} - */ - getExecutionDetails( - resource?: Resource, - ): Promise<{ - /** - * E.g of execution commands returned could be, - * * `['']` - * * `['']` - * * `['conda', 'run', 'python']` which is used to run from within Conda environments. - * or something similar for some other Python environments. - * - * @type {(string[] | undefined)} When return value is `undefined`, it means no interpreter is set. - * Otherwise, join the items returned using space to construct the full execution command. - */ - execCommand: string[] | undefined; - }>; - /** - * Returns the path to the python binary selected by the user or as in the settings. - * This is just the path to the python binary, this does not provide activation or any - * other activation command. The `resource` if provided will be used to determine the - * python binary in a multi-root scenario. If resource is `undefined` then the API - * returns what ever is set for the workspace. - * @param resource : Uri of a file or workspace - */ - getActiveEnvironmentPath(resource?: Resource): Promise; - /** - * Returns details for the given interpreter. Details such as absolute interpreter path, - * version, type (conda, pyenv, etc). Metadata such as `sysPrefix` can be found under - * metadata field. - * @param path : Full path to environment folder or interpreter whose details you need. - * @param options : [optional] - * * useCache : When true, cache is checked first for any data, returns even if there - * is partial data. - */ - getEnvironmentDetails( - path: string, - options?: EnvironmentDetailsOptions, - ): Promise; - /** - * Returns paths to environments that uniquely identifies an environment found by the extension - * at the time of calling. This API will *not* trigger a refresh. If a refresh is going on it - * will *not* wait for the refresh to finish. This will return what is known so far. To get - * complete list `await` on promise returned by `getRefreshPromise()`. - * - * Virtual environments lacking an interpreter are identified by environment folder paths, - * whereas other envs can be identified using interpreter path. - */ - getEnvironmentPaths(): Promise; - /** - * Sets the active environment path for the python extension for the resource. Configuration target - * will always be the workspace folder. - * @param path : Full path to environment folder or interpreter to set. - * @param resource : [optional] Uri of a file ro workspace to scope to a particular workspace - * folder. - */ - setActiveEnvironment(path: string, resource?: Resource): Promise; - /** - * This API will re-trigger environment discovery. Extensions can wait on the returned - * promise to get the updated environment list. If there is a refresh already going on - * then it returns the promise for that refresh. - * @param options : [optional] - * * clearCache : When true, this will clear the cache before environment refresh - * is triggered. - */ - refreshEnvironment(): Promise; - /** - * Tracks discovery progress for current list of known environments, i.e when it starts, finishes or any other relevant - * stage. Note the progress for a particular query is currently not tracked or reported, this only indicates progress of - * the entire collection. - */ - readonly onRefreshProgress: Event; - /** - * Returns a promise for the ongoing refresh. Returns `undefined` if there are no active - * refreshes going on. - */ - getRefreshPromise(options?: GetRefreshEnvironmentsOptions): Promise | undefined; - /** - * This event is triggered when the known environment list changes, like when a environment - * is found, existing environment is removed, or some details changed on an environment. - */ - onDidEnvironmentsChanged: Event; - /** - * This event is triggered when the active environment changes. - */ - onDidActiveEnvironmentChanged: Event; - }; -} diff --git a/src/client/common/application/extensions.ts b/src/client/common/application/extensions.ts index 359f31e15138..9d62e76d5da4 100644 --- a/src/client/common/application/extensions.ts +++ b/src/client/common/application/extensions.ts @@ -1,14 +1,25 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +/* eslint-disable class-methods-use-this */ +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. 'use strict'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { Event, Extension, extensions } from 'vscode'; +import * as stacktrace from 'stack-trace'; +import * as path from 'path'; import { IExtensions } from '../types'; +import { IFileSystem } from '../platform/types'; +import { EXTENSION_ROOT_DIR } from '../constants'; +/** + * Provides functions for tracking the list of extensions that VSCode has installed. + */ @injectable() export class Extensions implements IExtensions { + constructor(@inject(IFileSystem) private readonly fs: IFileSystem) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any public get all(): readonly Extension[] { return extensions.all; } @@ -17,7 +28,59 @@ export class Extensions implements IExtensions { return extensions.onDidChange; } - public getExtension(extensionId: any) { + public getExtension(extensionId: string): Extension | undefined { return extensions.getExtension(extensionId); } + + /** + * Code borrowed from: + * https://github.com/microsoft/vscode-jupyter/blob/67fe33d072f11d6443cf232a06bed0ac5e24682c/src/platform/common/application/extensions.node.ts + */ + public async determineExtensionFromCallStack(): Promise<{ extensionId: string; displayName: string }> { + const { stack } = new Error(); + if (stack) { + const pythonExtRoot = path.join(EXTENSION_ROOT_DIR.toLowerCase(), path.sep); + const frames = stack + .split('\n') + .map((f) => { + const result = /\((.*)\)/.exec(f); + if (result) { + return result[1]; + } + return undefined; + }) + .filter((item) => item && !item.toLowerCase().startsWith(pythonExtRoot)) + .filter((item) => + this.all.some( + (ext) => item!.includes(ext.extensionUri.path) || item!.includes(ext.extensionUri.fsPath), + ), + ) as string[]; + stacktrace.parse(new Error('Ex')).forEach((item) => { + const fileName = item.getFileName(); + if (fileName && !fileName.toLowerCase().startsWith(pythonExtRoot)) { + frames.push(fileName); + } + }); + for (const frame of frames) { + // This file is from a different extension. Try to find its `package.json`. + let dirName = path.dirname(frame); + let last = frame; + while (dirName && dirName.length < last.length) { + const possiblePackageJson = path.join(dirName, 'package.json'); + if (await this.fs.pathExists(possiblePackageJson)) { + const text = await this.fs.readFile(possiblePackageJson); + try { + const json = JSON.parse(text); + return { extensionId: `${json.publisher}.${json.name}`, displayName: json.displayName }; + } catch { + // If parse fails, then not an extension. + } + } + last = dirName; + dirName = path.dirname(dirName); + } + } + } + return { extensionId: 'unknown', displayName: 'unknown' }; + } } diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 571a9a01b8a2..66c91b13444d 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -428,6 +428,11 @@ export interface IExtensions { * @return An extension or `undefined`. */ getExtension(extensionId: string): Extension | undefined; + + /** + * Determines which extension called into our extension code based on call stacks. + */ + determineExtensionFromCallStack(): Promise<{ extensionId: string; displayName: string }>; } export const IBrowserService = Symbol('IBrowserService'); diff --git a/src/client/deprecatedProposedApi.ts b/src/client/deprecatedProposedApi.ts new file mode 100644 index 000000000000..84340772901a --- /dev/null +++ b/src/client/deprecatedProposedApi.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ConfigurationTarget, EventEmitter } from 'vscode'; +import { arePathsSame } from './common/platform/fs-paths'; +import { IExtensions, IInterpreterPathService, Resource } from './common/types'; +import { + EnvironmentsChangedParams, + ActiveEnvironmentChangedParams, + EnvironmentDetailsOptions, + EnvironmentDetails, + DeprecatedProposedAPI, +} from './deprecatedProposedApiTypes'; +import { IInterpreterService } from './interpreter/contracts'; +import { IServiceContainer } from './ioc/types'; +import { PythonEnvInfo } from './pythonEnvironments/base/info'; +import { getEnvPath } from './pythonEnvironments/base/info/env'; +import { GetRefreshEnvironmentsOptions, IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { sendTelemetryEvent } from './telemetry'; +import { EventName } from './telemetry/constants'; + +const onDidInterpretersChangedEvent = new EventEmitter(); +/** + * @deprecated Will be removed soon. + */ +export function reportInterpretersChanged(e: EnvironmentsChangedParams[]): void { + onDidInterpretersChangedEvent.fire(e); +} + +const onDidActiveInterpreterChangedEvent = new EventEmitter(); +/** + * @deprecated Will be removed soon. + */ +export function reportActiveInterpreterChangedDeprecated(e: ActiveEnvironmentChangedParams): void { + onDidActiveInterpreterChangedEvent.fire(e); +} + +function getVersionString(env: PythonEnvInfo): string[] { + const ver = [`${env.version.major}`, `${env.version.minor}`, `${env.version.micro}`]; + if (env.version.release) { + ver.push(`${env.version.release}`); + if (env.version.sysVersion) { + ver.push(`${env.version.release}`); + } + } + return ver; +} + +/** + * Returns whether the path provided matches the environment. + * @param path Path to environment folder or path to interpreter that uniquely identifies an environment. + * @param env Environment to match with. + */ +function isEnvSame(path: string, env: PythonEnvInfo) { + return arePathsSame(path, env.location) || arePathsSame(path, env.executable.filename); +} + +export function buildDeprecatedProposedApi( + discoveryApi: IDiscoveryAPI, + serviceContainer: IServiceContainer, +): DeprecatedProposedAPI { + const interpreterPathService = serviceContainer.get(IInterpreterPathService); + const interpreterService = serviceContainer.get(IInterpreterService); + const extensions = serviceContainer.get(IExtensions); + function sendApiTelemetry(apiName: string, warnLog = true) { + if (warnLog) { + console.warn('Extension is using deprecated python APIs which will be removed soon'); + } + extensions + .determineExtensionFromCallStack() + .then((info) => + sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { + apiName, + extensionId: info.extensionId, + displayName: info.displayName, + }), + ) + .ignoreErrors(); + } + + const proposed: DeprecatedProposedAPI = { + environment: { + async getExecutionDetails(resource?: Resource) { + sendApiTelemetry('getExecutionDetails'); + const env = await interpreterService.getActiveInterpreter(resource); + return env ? { execCommand: [env.path] } : { execCommand: undefined }; + }, + async getActiveEnvironmentPath(resource?: Resource) { + sendApiTelemetry('getActiveEnvironmentPath'); + const env = await interpreterService.getActiveInterpreter(resource); + if (!env) { + return undefined; + } + return getEnvPath(env.path, env.envPath); + }, + async getEnvironmentDetails( + path: string, + options?: EnvironmentDetailsOptions, + ): Promise { + sendApiTelemetry('getEnvironmentDetails'); + let env: PythonEnvInfo | undefined; + if (options?.useCache) { + env = discoveryApi.getEnvs().find((v) => isEnvSame(path, v)); + } + if (!env) { + env = await discoveryApi.resolveEnv(path); + if (!env) { + return undefined; + } + } + return { + interpreterPath: env.executable.filename, + envFolderPath: env.location.length ? env.location : undefined, + version: getVersionString(env), + environmentType: [env.kind], + metadata: { + sysPrefix: env.executable.sysPrefix, + bitness: env.arch, + project: env.searchLocation, + }, + }; + }, + getEnvironmentPaths() { + sendApiTelemetry('getEnvironmentPaths'); + const paths = discoveryApi.getEnvs().map((e) => getEnvPath(e.executable.filename, e.location)); + return Promise.resolve(paths); + }, + setActiveEnvironment(path: string, resource?: Resource): Promise { + sendApiTelemetry('setActiveEnvironment'); + return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); + }, + async refreshEnvironment() { + sendApiTelemetry('refreshEnvironment'); + await discoveryApi.triggerRefresh(); + const paths = discoveryApi.getEnvs().map((e) => getEnvPath(e.executable.filename, e.location)); + return Promise.resolve(paths); + }, + getRefreshPromise(options?: GetRefreshEnvironmentsOptions): Promise | undefined { + sendApiTelemetry('getRefreshPromise'); + return discoveryApi.getRefreshPromise(options); + }, + get onDidChangeExecutionDetails() { + sendApiTelemetry('onDidChangeExecutionDetails', false); + return interpreterService.onDidChangeInterpreterConfiguration; + }, + get onDidEnvironmentsChanged() { + sendApiTelemetry('onDidEnvironmentsChanged', false); + return onDidInterpretersChangedEvent.event; + }, + get onDidActiveEnvironmentChanged() { + sendApiTelemetry('onDidActiveEnvironmentChanged', false); + return onDidActiveInterpreterChangedEvent.event; + }, + get onRefreshProgress() { + sendApiTelemetry('onRefreshProgress', false); + return discoveryApi.onProgress; + }, + }, + }; + return proposed; +} diff --git a/src/client/deprecatedProposedApiTypes.ts b/src/client/deprecatedProposedApiTypes.ts new file mode 100644 index 000000000000..cf6c01f21219 --- /dev/null +++ b/src/client/deprecatedProposedApiTypes.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri, Event } from 'vscode'; +import { Resource } from './proposedApiTypes'; +import { PythonEnvKind, EnvPathType } from './pythonEnvironments/base/info'; +import { ProgressNotificationEvent, GetRefreshEnvironmentsOptions } from './pythonEnvironments/base/locator'; + +export interface EnvironmentDetailsOptions { + useCache: boolean; +} + +export interface EnvironmentDetails { + interpreterPath: string; + envFolderPath?: string; + version: string[]; + environmentType: PythonEnvKind[]; + metadata: Record; +} + +export interface EnvironmentsChangedParams { + /** + * Path to environment folder or path to interpreter that uniquely identifies an environment. + * Virtual environments lacking an interpreter are identified by environment folder paths, + * whereas other envs can be identified using interpreter path. + */ + path?: string; + type: 'add' | 'remove' | 'update' | 'clear-all'; +} + +export interface ActiveEnvironmentChangedParams { + /** + * Path to environment folder or path to interpreter that uniquely identifies an environment. + * Virtual environments lacking an interpreter are identified by environment folder paths, + * whereas other envs can be identified using interpreter path. + */ + path: string; + resource?: Uri; +} + +/** + * @deprecated Use {@link ProposedExtensionAPI} instead. + */ +export interface DeprecatedProposedAPI { + /** + * @deprecated Use {@link ProposedExtensionAPI.environment} instead. This will soon be removed. + */ + environment: { + /** + * An event that is emitted when execution details (for a resource) change. For instance, when interpreter configuration changes. + */ + readonly onDidChangeExecutionDetails: Event; + /** + * Returns all the details the consumer needs to execute code within the selected environment, + * corresponding to the specified resource taking into account any workspace-specific settings + * for the workspace to which this resource belongs. + * @param {Resource} [resource] A resource for which the setting is asked for. + * * When no resource is provided, the setting scoped to the first workspace folder is returned. + * * If no folder is present, it returns the global setting. + * @returns {({ execCommand: string[] | undefined })} + */ + getExecutionDetails( + resource?: Resource, + ): Promise<{ + /** + * E.g of execution commands returned could be, + * * `['']` + * * `['']` + * * `['conda', 'run', 'python']` which is used to run from within Conda environments. + * or something similar for some other Python environments. + * + * @type {(string[] | undefined)} When return value is `undefined`, it means no interpreter is set. + * Otherwise, join the items returned using space to construct the full execution command. + */ + execCommand: string[] | undefined; + }>; + /** + * @deprecated Use {@link getActiveEnvironmentId} instead. This will soon be removed. + */ + getActiveEnvironmentPath(resource?: Resource): Promise; + /** + * Returns details for the given interpreter. Details such as absolute interpreter path, + * version, type (conda, pyenv, etc). Metadata such as `sysPrefix` can be found under + * metadata field. + * @param path : Full path to environment folder or interpreter whose details you need. + * @param options : [optional] + * * useCache : When true, cache is checked first for any data, returns even if there + * is partial data. + */ + getEnvironmentDetails( + path: string, + options?: EnvironmentDetailsOptions, + ): Promise; + /** + * Returns paths to environments that uniquely identifies an environment found by the extension + * at the time of calling. This API will *not* trigger a refresh. If a refresh is going on it + * will *not* wait for the refresh to finish. This will return what is known so far. To get + * complete list `await` on promise returned by `getRefreshPromise()`. + * + * Virtual environments lacking an interpreter are identified by environment folder paths, + * whereas other envs can be identified using interpreter path. + */ + getEnvironmentPaths(): Promise; + /** + * Sets the active environment path for the python extension for the resource. Configuration target + * will always be the workspace folder. + * @param path : Full path to environment folder or interpreter to set. + * @param resource : [optional] Uri of a file ro workspace to scope to a particular workspace + * folder. + */ + setActiveEnvironment(path: string, resource?: Resource): Promise; + /** + * This API will re-trigger environment discovery. Extensions can wait on the returned + * promise to get the updated environment list. If there is a refresh already going on + * then it returns the promise for that refresh. + * @param options : [optional] + * * clearCache : When true, this will clear the cache before environment refresh + * is triggered. + */ + refreshEnvironment(): Promise; + /** + * Tracks discovery progress for current list of known environments, i.e when it starts, finishes or any other relevant + * stage. Note the progress for a particular query is currently not tracked or reported, this only indicates progress of + * the entire collection. + */ + readonly onRefreshProgress: Event; + /** + * Returns a promise for the ongoing refresh. Returns `undefined` if there are no active + * refreshes going on. + */ + getRefreshPromise(options?: GetRefreshEnvironmentsOptions): Promise | undefined; + /** + * This event is triggered when the known environment list changes, like when a environment + * is found, existing environment is removed, or some details changed on an environment. + */ + onDidEnvironmentsChanged: Event; + /** + * @deprecated Use {@link ProposedExtensionAPI.environment} `onDidChangeActiveEnvironmentId` instead. This will soon be removed. + */ + onDidActiveEnvironmentChanged: Event; + }; +} diff --git a/src/client/extension.ts b/src/client/extension.ts index 6069583489ef..04ad91f80c84 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -42,10 +42,11 @@ import { sendErrorTelemetry, sendStartupTelemetry } from './startupTelemetry'; import { IStartupDurations } from './types'; import { runAfterActivation } from './common/utils/runAfterActivation'; import { IInterpreterService } from './interpreter/contracts'; -import { IExtensionApi, IProposedExtensionAPI } from './apiTypes'; +import { IExtensionApi } from './apiTypes'; import { buildProposedApi } from './proposedApi'; import { WorkspaceService } from './common/application/workspace'; import { disposeAll } from './common/utils/resourceLifecycle'; +import { ProposedExtensionAPI } from './proposedApiTypes'; durations.codeLoadingTime = stopWatch.elapsedTime; @@ -103,7 +104,7 @@ async function activateUnsafe( context: IExtensionContext, startupStopWatch: StopWatch, startupDurations: IStartupDurations, -): Promise<[IExtensionApi & IProposedExtensionAPI, Promise, IServiceContainer]> { +): Promise<[IExtensionApi & ProposedExtensionAPI, Promise, IServiceContainer]> { // Add anything that we got from initializing logs to dispose. context.subscriptions.push(...logDispose); diff --git a/src/client/interpreter/interpreterService.ts b/src/client/interpreter/interpreterService.ts index cc4bf786dd6d..50545558d721 100644 --- a/src/client/interpreter/interpreterService.ts +++ b/src/client/interpreter/interpreterService.ts @@ -11,7 +11,7 @@ import { Uri, } from 'vscode'; import '../common/extensions'; -import { IApplicationShell, IDocumentManager } from '../common/application/types'; +import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../common/application/types'; import { IConfigurationService, IDisposableRegistry, @@ -222,7 +222,7 @@ export class InterpreterService implements Disposable, IInterpreterService { this.didChangeInterpreterEmitter.fire(); reportActiveInterpreterChanged({ path: pySettings.pythonPath, - resource, + resource: this.serviceContainer.get(IWorkspaceService).getWorkspaceFolder(resource), }); const interpreterDisplay = this.serviceContainer.get(IInterpreterDisplay); interpreterDisplay.refresh().catch((ex) => traceError('Python Extension: display.refresh', ex)); diff --git a/src/client/proposedApi.ts b/src/client/proposedApi.ts index fc432efeb821..e85c4009d2c9 100644 --- a/src/client/proposedApi.ts +++ b/src/client/proposedApi.ts @@ -1,118 +1,334 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ConfigurationTarget, EventEmitter } from 'vscode'; -import { - ActiveEnvironmentChangedParams, - EnvironmentDetails, - EnvironmentDetailsOptions, - EnvironmentsChangedParams, - IProposedExtensionAPI, -} from './apiTypes'; -import { arePathsSame } from './common/platform/fs-paths'; -import { IInterpreterPathService, Resource } from './common/types'; -import { IInterpreterService } from './interpreter/contracts'; +import { ConfigurationTarget, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; +import * as pathUtils from 'path'; +import { IConfigurationService, IDisposableRegistry, IExtensions, IInterpreterPathService } from './common/types'; +import { Architecture } from './common/utils/platform'; import { IServiceContainer } from './ioc/types'; -import { PythonEnvInfo } from './pythonEnvironments/base/info'; +import { + ActiveEnvironmentIdChangeEvent, + Environment, + EnvironmentsChangeEvent, + ProposedExtensionAPI, + ResolvedEnvironment, + RefreshOptions, + Resource, + EnvironmentType, + EnvironmentTools, + EnvironmentId, +} from './proposedApiTypes'; +import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from './pythonEnvironments/base/info'; import { getEnvPath } from './pythonEnvironments/base/info/env'; -import { GetRefreshEnvironmentsOptions, IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { IPythonExecutionFactory } from './common/process/types'; +import { traceError } from './logging'; +import { normCasePath } from './common/platform/fs-paths'; +import { sendTelemetryEvent } from './telemetry'; +import { EventName } from './telemetry/constants'; +import { + buildDeprecatedProposedApi, + reportActiveInterpreterChangedDeprecated, + reportInterpretersChanged, +} from './deprecatedProposedApi'; -const onDidInterpretersChangedEvent = new EventEmitter(); -export function reportInterpretersChanged(e: EnvironmentsChangedParams[]): void { - onDidInterpretersChangedEvent.fire(e); -} +type ActiveEnvironmentChangeEvent = { + resource: WorkspaceFolder | undefined; + path: string; +}; -const onDidActiveInterpreterChangedEvent = new EventEmitter(); -export function reportActiveInterpreterChanged(e: ActiveEnvironmentChangedParams): void { - onDidActiveInterpreterChangedEvent.fire(e); +const onDidActiveInterpreterChangedEvent = new EventEmitter(); +export function reportActiveInterpreterChanged(e: ActiveEnvironmentChangeEvent): void { + onDidActiveInterpreterChangedEvent.fire({ id: getEnvID(e.path), path: e.path, resource: e.resource }); + reportActiveInterpreterChangedDeprecated({ path: e.path, resource: e.resource?.uri }); } -function getVersionString(env: PythonEnvInfo): string[] { - const ver = [`${env.version.major}`, `${env.version.minor}`, `${env.version.micro}`]; - if (env.version.release) { - ver.push(`${env.version.release}`); - if (env.version.sysVersion) { - ver.push(`${env.version.release}`); - } - } - return ver; -} +const onEnvironmentsChanged = new EventEmitter(); +const environmentsReference = new Map(); /** - * Returns whether the path provided matches the environment. - * @param path Path to environment folder or path to interpreter that uniquely identifies an environment. - * @param env Environment to match with. + * Make all properties in T mutable. */ -function isEnvSame(path: string, env: PythonEnvInfo) { - return arePathsSame(path, env.location) || arePathsSame(path, env.executable.filename); +type Mutable = { + -readonly [P in keyof T]: Mutable; +}; + +export class EnvironmentReference implements Environment { + readonly id: string; + + constructor(public internal: Environment) { + this.id = internal.id; + } + + get executable() { + return Object.freeze(this.internal.executable); + } + + get environment() { + return Object.freeze(this.internal.environment); + } + + get version() { + return Object.freeze(this.internal.version); + } + + get tools() { + return Object.freeze(this.internal.tools); + } + + get path() { + return Object.freeze(this.internal.path); + } + + updateEnv(newInternal: Environment) { + this.internal = newInternal; + } +} + +function getEnvReference(e: Environment) { + let envClass = environmentsReference.get(e.id); + if (!envClass) { + envClass = new EnvironmentReference(e); + } else { + envClass.updateEnv(e); + } + environmentsReference.set(e.id, envClass); + return envClass; } export function buildProposedApi( discoveryApi: IDiscoveryAPI, serviceContainer: IServiceContainer, -): IProposedExtensionAPI { +): ProposedExtensionAPI { const interpreterPathService = serviceContainer.get(IInterpreterPathService); - const interpreterService = serviceContainer.get(IInterpreterService); + const configService = serviceContainer.get(IConfigurationService); + const disposables = serviceContainer.get(IDisposableRegistry); + const extensions = serviceContainer.get(IExtensions); + function sendApiTelemetry(apiName: string) { + extensions + .determineExtensionFromCallStack() + .then((info) => + sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { + apiName, + extensionId: info.extensionId, + displayName: info.displayName, + }), + ) + .ignoreErrors(); + } + disposables.push( + discoveryApi.onChanged((e) => { + if (e.old) { + if (e.new) { + onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'update', + }, + ]); + } else { + onEnvironmentsChanged.fire({ type: 'remove', env: convertEnvInfoAndGetReference(e.old) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.old.executable.filename, e.old.location).path, + type: 'remove', + }, + ]); + } + } else if (e.new) { + onEnvironmentsChanged.fire({ type: 'add', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'add', + }, + ]); + } + }), + onEnvironmentsChanged, + ); + + /** + * @deprecated Will be removed soon. Use {@link ProposedExtensionAPI.environment} instead. + */ + let deprecatedEnvironmentsApi; + try { + deprecatedEnvironmentsApi = { ...buildDeprecatedProposedApi(discoveryApi, serviceContainer).environment }; + } catch (ex) { + deprecatedEnvironmentsApi = {}; + // Errors out only in case of testing. + // Also, these APIs no longer supported, no need to log error. + } - const proposed: IProposedExtensionAPI = { + const proposed: ProposedExtensionAPI = { environment: { - async getExecutionDetails(resource?: Resource) { - const env = await interpreterService.getActiveInterpreter(resource); - return env ? { execCommand: [env.path] } : { execCommand: undefined }; + getActiveEnvironmentId(resource?: Resource) { + sendApiTelemetry('getActiveEnvironmentId'); + resource = resource && 'uri' in resource ? resource.uri : resource; + const path = configService.getSettings(resource).pythonPath; + const id = path === 'python' ? 'DEFAULT_PYTHON' : getEnvID(path); + return { id, path }; }, - async getActiveEnvironmentPath(resource?: Resource) { - const env = await interpreterService.getActiveInterpreter(resource); - if (!env) { - return undefined; - } - return getEnvPath(env.path, env.envPath); + updateActiveEnvironmentId(env: Environment | EnvironmentId | string, resource?: Resource): Promise { + sendApiTelemetry('updateActiveEnvironmentId'); + const path = typeof env !== 'string' ? env.path : env; + resource = resource && 'uri' in resource ? resource.uri : resource; + return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); }, - async getEnvironmentDetails( - path: string, - options?: EnvironmentDetailsOptions, - ): Promise { - let env: PythonEnvInfo | undefined; - if (options?.useCache) { - env = discoveryApi.getEnvs().find((v) => isEnvSame(path, v)); - } - if (!env) { - env = await discoveryApi.resolveEnv(path); - if (!env) { + get onDidChangeActiveEnvironmentId() { + sendApiTelemetry('onDidChangeActiveEnvironmentId'); + return onDidActiveInterpreterChangedEvent.event; + }, + resolveEnvironment: async (env: Environment | EnvironmentId | string) => { + let path = typeof env !== 'string' ? env.path : env; + if (pathUtils.basename(path) === path) { + // Value can be `python`, `python3`, `python3.9` etc. + // This case could eventually be handled by the internal discovery API itself. + const pythonExecutionFactory = serviceContainer.get( + IPythonExecutionFactory, + ); + const pythonExecutionService = await pythonExecutionFactory.create({ pythonPath: path }); + const fullyQualifiedPath = await pythonExecutionService.getExecutablePath().catch((ex) => { + traceError('Cannot resolve full path', ex); + return undefined; + }); + // Python path is invalid or python isn't installed. + if (!fullyQualifiedPath) { return undefined; } + path = fullyQualifiedPath; } - return { - interpreterPath: env.executable.filename, - envFolderPath: env.location.length ? env.location : undefined, - version: getVersionString(env), - environmentType: [env.kind], - metadata: { - sysPrefix: env.executable.sysPrefix, - bitness: env.arch, - project: env.searchLocation, - }, - }; - }, - getEnvironmentPaths() { - const paths = discoveryApi.getEnvs().map((e) => getEnvPath(e.executable.filename, e.location)); - return Promise.resolve(paths); + sendApiTelemetry('resolveEnvironment'); + return resolveEnvironment(path, discoveryApi); }, - setActiveEnvironment(path: string, resource?: Resource): Promise { - return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); + get all(): Environment[] { + sendApiTelemetry('all'); + return discoveryApi.getEnvs().map((e) => convertEnvInfoAndGetReference(e)); }, - async refreshEnvironment() { - await discoveryApi.triggerRefresh(); - const paths = discoveryApi.getEnvs().map((e) => getEnvPath(e.executable.filename, e.location)); - return Promise.resolve(paths); + async refreshEnvironments(options?: RefreshOptions) { + await discoveryApi.triggerRefresh(undefined, { + ifNotTriggerredAlready: !options?.forceRefresh, + }); + sendApiTelemetry('refreshEnvironments'); }, - getRefreshPromise(options?: GetRefreshEnvironmentsOptions): Promise | undefined { - return discoveryApi.getRefreshPromise(options); + get onDidChangeEnvironments() { + sendApiTelemetry('onDidChangeEnvironments'); + return onEnvironmentsChanged.event; }, - onDidChangeExecutionDetails: interpreterService.onDidChangeInterpreterConfiguration, - onDidEnvironmentsChanged: onDidInterpretersChangedEvent.event, - onDidActiveEnvironmentChanged: onDidActiveInterpreterChangedEvent.event, - onRefreshProgress: discoveryApi.onProgress, + ...deprecatedEnvironmentsApi, }, }; return proposed; } + +async function resolveEnvironment(path: string, discoveryApi: IDiscoveryAPI): Promise { + const env = await discoveryApi.resolveEnv(path); + if (!env) { + return undefined; + } + return getEnvReference(convertCompleteEnvInfo(env)) as ResolvedEnvironment; +} + +export function convertCompleteEnvInfo(env: PythonEnvInfo): ResolvedEnvironment { + const version = { ...env.version, sysVersion: env.version.sysVersion }; + let tool = convertKind(env.kind); + if (env.type && !tool) { + tool = 'Unknown'; + } + const { path } = getEnvPath(env.executable.filename, env.location); + const resolvedEnv: ResolvedEnvironment = { + path, + id: getEnvID(path), + executable: { + uri: Uri.file(env.executable.filename), + bitness: convertBitness(env.arch), + sysPrefix: env.executable.sysPrefix, + }, + environment: env.type + ? { + type: convertEnvType(env.type), + name: env.name, + folderUri: Uri.file(env.location), + workspaceFolder: env.searchLocation, + } + : undefined, + version: version as ResolvedEnvironment['version'], + tools: tool ? [tool] : [], + }; + return resolvedEnv; +} + +function convertEnvType(envType: PythonEnvType): EnvironmentType { + if (envType === PythonEnvType.Conda) { + return 'Conda'; + } + if (envType === PythonEnvType.Virtual) { + return 'VirtualEnvironment'; + } + return 'Unknown'; +} + +function convertKind(kind: PythonEnvKind): EnvironmentTools | undefined { + switch (kind) { + case PythonEnvKind.Venv: + return 'Venv'; + case PythonEnvKind.Pipenv: + return 'Pipenv'; + case PythonEnvKind.Poetry: + return 'Poetry'; + case PythonEnvKind.VirtualEnvWrapper: + return 'VirtualEnvWrapper'; + case PythonEnvKind.VirtualEnv: + return 'VirtualEnv'; + case PythonEnvKind.Conda: + return 'Conda'; + case PythonEnvKind.Pyenv: + return 'Pyenv'; + default: + return undefined; + } +} + +export function convertEnvInfo(env: PythonEnvInfo): Environment { + const convertedEnv = convertCompleteEnvInfo(env) as Mutable; + if (convertedEnv.executable.sysPrefix === '') { + convertedEnv.executable.sysPrefix = undefined; + } + if (convertedEnv.executable.uri?.fsPath === 'python') { + convertedEnv.executable.uri = undefined; + } + if (convertedEnv.environment?.name === '') { + convertedEnv.environment.name = undefined; + } + if (convertedEnv.version.major === -1) { + convertedEnv.version.major = undefined; + } + if (convertedEnv.version.micro === -1) { + convertedEnv.version.micro = undefined; + } + if (convertedEnv.version.minor === -1) { + convertedEnv.version.minor = undefined; + } + return convertedEnv as Environment; +} + +function convertEnvInfoAndGetReference(env: PythonEnvInfo): Environment { + return getEnvReference(convertEnvInfo(env)); +} + +function convertBitness(arch: Architecture) { + switch (arch) { + case Architecture.x64: + return '64-bit'; + case Architecture.x86: + return '32-bit'; + default: + return 'Unknown'; + } +} + +function getEnvID(path: string) { + return normCasePath(path); +} diff --git a/src/client/proposedApiTypes.ts b/src/client/proposedApiTypes.ts new file mode 100644 index 000000000000..89f9f1e4e5f4 --- /dev/null +++ b/src/client/proposedApiTypes.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, Event, Uri, WorkspaceFolder } from 'vscode'; + +// https://github.com/microsoft/vscode-python/wiki/Proposed-Environment-APIs + +export interface ProposedExtensionAPI { + readonly environment: { + /** + * Returns the environment configured by user in settings. + * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root + * scenario. If `undefined`, then the API returns what ever is set for the workspace. + */ + getActiveEnvironmentId(resource?: Resource): EnvironmentId; + /** + * Sets the active environment path for the python extension for the resource. Configuration target will always + * be the workspace folder. + * @param environment : Full path to environment folder or python executable for the environment. Can also pass + * the environment itself. + * @param resource : [optional] File or workspace to scope to a particular workspace folder. + */ + updateActiveEnvironmentId( + environment: Environment | EnvironmentId | string, + resource?: Resource, + ): Promise; + /** + * This event is triggered when the active environment setting changes. + */ + readonly onDidChangeActiveEnvironmentId: Event; + /** + * Carries environments found by the extension at the time of fetching the property. Note this may not + * contain all environments in the system as a refresh might be going on. + */ + readonly all: readonly Environment[]; + /** + * This event is triggered when the known environment list changes, like when a environment + * is found, existing environment is removed, or some details changed on an environment. + */ + readonly onDidChangeEnvironments: Event; + /** + * This API will trigger environment discovery, but only if it has not already happened in this VSCode session. + * Useful for making sure env list is up-to-date when the caller needs it for the first time. + * + * To force trigger a refresh regardless of whether a refresh was already triggered, see option + * {@link RefreshOptions.forceRefresh}. + * + * Note that if there is a refresh already going on then this returns the promise for that refresh. + * @param options Additional options for refresh. + * @param token A cancellation token that indicates a refresh is no longer needed. + */ + refreshEnvironments(options?: RefreshOptions, token?: CancellationToken): Promise; + /** + * Returns details for the given environment, or `undefined` if the env is invalid. + * @param environment : Full path to environment folder or python executable for the environment. Can also pass + * the environment id or the environment itself. + */ + resolveEnvironment(environment: Environment | EnvironmentId | string): Promise; + }; +} + +export type RefreshOptions = { + /** + * Force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so + * it's best to only use it if user manually triggers a refresh. + */ + forceRefresh?: boolean; +}; + +/** + * Details about the environment. Note the environment folder, type and name never changes over time. + */ +export type Environment = EnvironmentId & { + /** + * Carries details about python executable. + */ + readonly executable: { + /** + * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to + * the environment. + */ + readonly uri: Uri | undefined; + /** + * Bitness if known at this moment. + */ + readonly bitness: Bitness | undefined; + /** + * Value of `sys.prefix` in sys module if known at this moment. + */ + readonly sysPrefix: string | undefined; + }; + /** + * Carries details if it is an environment, otherwise `undefined` in case of global interpreters and others. + */ + readonly environment: + | { + /** + * Type of the environment. + */ + readonly type: EnvironmentType; + /** + * Name to the environment if any. + */ + readonly name: string | undefined; + /** + * Uri of the environment folder. + */ + readonly folderUri: Uri; + /** + * Any specific workspace folder this environment is created for. + */ + readonly workspaceFolder: Uri | undefined; + } + | undefined; + /** + * Carries Python version information known at this moment. + */ + readonly version: VersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string | undefined; + }; + /** + * Tools/plugins which created the environment or where it came from. First value in array corresponds + * to the primary tool which manages the environment, which never changes over time. + * + * Array is empty if no tool is responsible for creating/managing the environment. Usually the case for + * global interpreters. + */ + readonly tools: readonly EnvironmentTools[]; +}; + +/** + * Derived form of {@link Environment} where certain properties can no longer be `undefined`. Meant to represent an + * {@link Environment} with complete information. + */ +export type ResolvedEnvironment = Environment & { + /** + * Carries complete details about python executable. + */ + readonly executable: { + /** + * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to + * the environment. + */ + readonly uri: Uri | undefined; + /** + * Bitness of the environment. + */ + readonly bitness: Bitness; + /** + * Value of `sys.prefix` in sys module. + */ + readonly sysPrefix: string; + }; + /** + * Carries complete Python version information. + */ + readonly version: ResolvedVersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string; + }; +}; + +export type EnvironmentsChangeEvent = { + readonly env: Environment; + /** + * * "add": New environment is added. + * * "remove": Existing environment in the list is removed. + * * "update": New information found about existing environment. + */ + readonly type: 'add' | 'remove' | 'update'; +}; + +export type ActiveEnvironmentIdChangeEvent = EnvironmentId & { + /** + * Workspace folder the environment changed for. + */ + readonly resource: WorkspaceFolder | undefined; +}; + +/** + * Uri of a file inside a workspace or workspace folder itself. + */ +export type Resource = Uri | WorkspaceFolder; + +export type EnvironmentId = { + /** + * The ID of the environment. + */ + readonly id: string; + /** + * Path to environment folder or path to python executable that uniquely identifies an environment. Environments + * lacking a python executable are identified by environment folder paths, whereas other envs can be identified + * using python executable path. + */ + readonly path: string; +}; + +/** + * Tool/plugin where the environment came from. It can be {@link KnownEnvironmentTools} or custom string which + * was contributed. + */ +export type EnvironmentTools = KnownEnvironmentTools | string; +/** + * Tools or plugins the Python extension currently has built-in support for. Note this list is expected to shrink + * once tools have their own separate extensions. + */ +export type KnownEnvironmentTools = + | 'Conda' + | 'Pipenv' + | 'Poetry' + | 'VirtualEnv' + | 'Venv' + | 'VirtualEnvWrapper' + | 'Pyenv' + | 'Unknown'; + +/** + * Type of the environment. It can be {@link KnownEnvironmentTypes} or custom string which was contributed. + */ +export type EnvironmentType = KnownEnvironmentTypes | string; +/** + * Environment types the Python extension is aware of. Note this list is expected to shrink once tools have their + * own separate extensions, in which case they're expected to provide the type themselves. + */ +export type KnownEnvironmentTypes = 'VirtualEnvironment' | 'Conda' | 'Unknown'; + +/** + * Carries bitness for an environment. + */ +export type Bitness = '64-bit' | '32-bit' | 'Unknown'; + +/** + * The possible Python release levels. + */ +export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; + +/** + * Release information for a Python version. + */ +export type PythonVersionRelease = { + readonly level: PythonReleaseLevel; + readonly serial: number; +}; + +export type VersionInfo = { + readonly major: number | undefined; + readonly minor: number | undefined; + readonly micro: number | undefined; + readonly release: PythonVersionRelease | undefined; +}; + +export type ResolvedVersionInfo = { + readonly major: number; + readonly minor: number; + readonly micro: number; + readonly release: PythonVersionRelease; +}; diff --git a/src/client/pythonEnvironments/api.ts b/src/client/pythonEnvironments/api.ts index b9c3152a0b67..a2065c30b740 100644 --- a/src/client/pythonEnvironments/api.ts +++ b/src/client/pythonEnvironments/api.ts @@ -6,6 +6,7 @@ import { GetRefreshEnvironmentsOptions, IDiscoveryAPI, ProgressNotificationEvent, + ProgressReportStage, PythonLocatorQuery, TriggerRefreshOptions, } from './base/locator'; @@ -33,6 +34,10 @@ class PythonEnvironments implements IDiscoveryAPI { return this.locator.onProgress; } + public get refreshState(): ProgressReportStage { + return this.locator.refreshState; + } + public getRefreshPromise(options?: GetRefreshEnvironmentsOptions) { return this.locator.getRefreshPromise(options); } diff --git a/src/client/pythonEnvironments/base/locator.ts b/src/client/pythonEnvironments/base/locator.ts index c0d1cd23991c..687348964891 100644 --- a/src/client/pythonEnvironments/base/locator.ts +++ b/src/client/pythonEnvironments/base/locator.ts @@ -201,6 +201,7 @@ export type TriggerRefreshOptions = { }; export interface IDiscoveryAPI { + readonly refreshState: ProgressReportStage; /** * Tracks discovery progress for current list of known environments, i.e when it starts, finishes or any other relevant * stage. Note the progress for a particular query is currently not tracked or reported, this only indicates progress of diff --git a/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts b/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts index 14663e2d117d..a8820a0f82b8 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts @@ -4,7 +4,6 @@ import { Event } from 'vscode'; import { isTestExecution } from '../../../../common/constants'; import { traceInfo } from '../../../../logging'; -import { reportInterpretersChanged } from '../../../../proposedApi'; import { arePathsSame, getFileInfo, pathExists } from '../../../common/externalDependencies'; import { PythonEnvInfo } from '../../info'; import { areSameEnv, getEnvPath } from '../../info/env'; @@ -113,9 +112,6 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher { const env = this.envs.splice(index, 1)[0]; this.fire({ old: env, new: undefined }); - reportInterpretersChanged([ - { path: getEnvPath(env.executable.filename, env.location).path, type: 'remove' }, - ]); }); } @@ -132,7 +128,6 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher { this.fire({ old: e, new: undefined }); }); - reportInterpretersChanged([{ path: undefined, type: 'clear-all' }]); this.envs = []; return Promise.resolve(); } diff --git a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts index 0e1466bc385d..ca7c93b1c269 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts @@ -43,6 +43,8 @@ export class EnvsCollectionService extends PythonEnvsWatcher(); + public refreshState = ProgressReportStage.discoveryFinished; + public get onProgress(): Event { return this.progress.event; } @@ -70,6 +72,7 @@ export class EnvsCollectionService extends PythonEnvsWatcher { + this.refreshState = event.stage; // Resolve progress promise indicating the stage has been reached. this.progressPromises.get(event.stage)?.resolve(); this.progressPromises.delete(event.stage); diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index 2ab6c8a8a3ba..4a611fcf3e7f 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -19,6 +19,7 @@ export enum EventName { PYTHON_INTERPRETER = 'PYTHON_INTERPRETER', PYTHON_INSTALL_PACKAGE = 'PYTHON_INSTALL_PACKAGE', ENVIRONMENT_WITHOUT_PYTHON_SELECTED = 'ENVIRONMENT_WITHOUT_PYTHON_SELECTED', + PYTHON_ENVIRONMENTS_API = 'PYTHON_ENVIRONMENTS_API', PYTHON_INTERPRETER_DISCOVERY = 'PYTHON_INTERPRETER_DISCOVERY', PYTHON_INTERPRETER_AUTO_SELECTION = 'PYTHON_INTERPRETER_AUTO_SELECTION', PYTHON_INTERPRETER_ACTIVATION_ENVIRONMENT_VARIABLES = 'PYTHON_INTERPRETER_ACTIVATION_ENVIRONMENT_VARIABLES', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index f8c443ff58ce..7211aa38e54f 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1107,6 +1107,31 @@ export interface IEventNamePropertyMapping { */ discovered: boolean; }; + + /** + * Telemetry event sent when another extension calls into python extension's environment API. Contains details + * of the other extension. + */ + /* __GDPR__ + "python_environments_api" : { + "extensionId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": false , "owner": "karrtikr"}, + "displayName" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": false, "owner": "karrtikr" } + } + */ + [EventName.PYTHON_ENVIRONMENTS_API]: { + /** + * The ID of the extension calling the API. + */ + extensionId: string; + /** + * The name of the extension as displayed in marketplace. + */ + displayName: string; + /** + * The name of the API called. + */ + apiName: string; + }; /** * Telemetry event sent with details after updating the python interpreter */ diff --git a/src/test/interpreters/interpreterService.unit.test.ts b/src/test/interpreters/interpreterService.unit.test.ts index 4ab2c8086309..1bbf729e53b9 100644 --- a/src/test/interpreters/interpreterService.unit.test.ts +++ b/src/test/interpreters/interpreterService.unit.test.ts @@ -248,6 +248,8 @@ suite('Interpreters service', () => { test('If stored setting is an empty string, refresh the interpreter display', async () => { const service = new InterpreterService(serviceContainer, pyenvs.object); const resource = Uri.parse('a'); + const workspaceFolder = { uri: resource, name: '', index: 0 }; + workspace.setup((w) => w.getWorkspaceFolder(resource)).returns(() => workspaceFolder); service._pythonPathSetting = ''; configService.reset(); configService.setup((c) => c.getSettings(resource)).returns(() => ({ pythonPath: 'current path' } as any)); @@ -259,13 +261,15 @@ suite('Interpreters service', () => { interpreterDisplay.verifyAll(); sinon.assert.calledOnceWithExactly(reportActiveInterpreterChangedStub, { path: 'current path', - resource, + resource: workspaceFolder, }); }); test('If stored setting is not equal to current interpreter path setting, refresh the interpreter display', async () => { const service = new InterpreterService(serviceContainer, pyenvs.object); const resource = Uri.parse('a'); + const workspaceFolder = { uri: resource, name: '', index: 0 }; + workspace.setup((w) => w.getWorkspaceFolder(resource)).returns(() => workspaceFolder); service._pythonPathSetting = 'stored setting'; configService.reset(); configService.setup((c) => c.getSettings(resource)).returns(() => ({ pythonPath: 'current path' } as any)); @@ -277,7 +281,7 @@ suite('Interpreters service', () => { interpreterDisplay.verifyAll(); sinon.assert.calledOnceWithExactly(reportActiveInterpreterChangedStub, { path: 'current path', - resource, + resource: workspaceFolder, }); }); diff --git a/src/test/proposedApi.unit.test.ts b/src/test/proposedApi.unit.test.ts index ad4cdc904a22..816bf1051d25 100644 --- a/src/test/proposedApi.unit.test.ts +++ b/src/test/proposedApi.unit.test.ts @@ -3,404 +3,393 @@ import * as typemoq from 'typemoq'; import { assert, expect } from 'chai'; -import { ConfigurationTarget, Uri, Event } from 'vscode'; -import { EnvironmentDetails, IProposedExtensionAPI } from '../client/apiTypes'; -import { IInterpreterPathService } from '../client/common/types'; -import { IInterpreterService } from '../client/interpreter/contracts'; +import { Uri, EventEmitter, ConfigurationTarget, WorkspaceFolder } from 'vscode'; +import { cloneDeep } from 'lodash'; +import { + IConfigurationService, + IDisposableRegistry, + IExtensions, + IInterpreterPathService, + IPythonSettings, +} from '../client/common/types'; import { IServiceContainer } from '../client/ioc/types'; -import { buildProposedApi } from '../client/proposedApi'; import { - IDiscoveryAPI, - ProgressNotificationEvent, - ProgressReportStage, -} from '../client/pythonEnvironments/base/locator'; -import { PythonEnvironment } from '../client/pythonEnvironments/info'; + buildProposedApi, + convertCompleteEnvInfo, + convertEnvInfo, + EnvironmentReference, + reportActiveInterpreterChanged, +} from '../client/proposedApi'; +import { IDiscoveryAPI, ProgressNotificationEvent } from '../client/pythonEnvironments/base/locator'; +import { buildEnvInfo } from '../client/pythonEnvironments/base/info/env'; +import { sleep } from './core'; import { PythonEnvKind, PythonEnvSource } from '../client/pythonEnvironments/base/info'; import { Architecture } from '../client/common/utils/platform'; -import { buildEnvInfo } from '../client/pythonEnvironments/base/info/env'; +import { PythonEnvCollectionChangedEvent } from '../client/pythonEnvironments/base/watcher'; +import { + ProposedExtensionAPI, + ActiveEnvironmentIdChangeEvent, + EnvironmentsChangeEvent, +} from '../client/proposedApiTypes'; +import { normCasePath } from '../client/common/platform/fs-paths'; suite('Proposed Extension API', () => { let serviceContainer: typemoq.IMock; let discoverAPI: typemoq.IMock; let interpreterPathService: typemoq.IMock; - let interpreterService: typemoq.IMock; - let onDidExecutionEvent: Event; - let onRefreshProgress: Event; + let configService: typemoq.IMock; + let extensions: typemoq.IMock; + let onDidChangeRefreshState: EventEmitter; + let onDidChangeEnvironments: EventEmitter; - let proposed: IProposedExtensionAPI; + let proposed: ProposedExtensionAPI; setup(() => { - serviceContainer = typemoq.Mock.ofType(undefined, typemoq.MockBehavior.Strict); - discoverAPI = typemoq.Mock.ofType(undefined, typemoq.MockBehavior.Strict); - interpreterPathService = typemoq.Mock.ofType(undefined, typemoq.MockBehavior.Strict); - interpreterService = typemoq.Mock.ofType(undefined, typemoq.MockBehavior.Strict); - onDidExecutionEvent = typemoq.Mock.ofType>().object; - onRefreshProgress = typemoq.Mock.ofType>().object; - interpreterService.setup((i) => i.onDidChangeInterpreterConfiguration).returns(() => onDidExecutionEvent); - + serviceContainer = typemoq.Mock.ofType(); + discoverAPI = typemoq.Mock.ofType(); + extensions = typemoq.Mock.ofType(); + extensions + .setup((e) => e.determineExtensionFromCallStack()) + .returns(() => Promise.resolve({ extensionId: 'id', displayName: 'displayName', apiName: 'apiName' })) + .verifiable(typemoq.Times.atLeastOnce()); + interpreterPathService = typemoq.Mock.ofType(); + configService = typemoq.Mock.ofType(); + onDidChangeRefreshState = new EventEmitter(); + onDidChangeEnvironments = new EventEmitter(); + + serviceContainer.setup((s) => s.get(IExtensions)).returns(() => extensions.object); serviceContainer.setup((s) => s.get(IInterpreterPathService)).returns(() => interpreterPathService.object); - serviceContainer.setup((s) => s.get(IInterpreterService)).returns(() => interpreterService.object); + serviceContainer.setup((s) => s.get(IConfigurationService)).returns(() => configService.object); + serviceContainer.setup((s) => s.get(IDisposableRegistry)).returns(() => []); - discoverAPI.setup((d) => d.onProgress).returns(() => onRefreshProgress); + discoverAPI.setup((d) => d.onProgress).returns(() => onDidChangeRefreshState.event); + discoverAPI.setup((d) => d.onChanged).returns(() => onDidChangeEnvironments.event); proposed = buildProposedApi(discoverAPI.object, serviceContainer.object); }); - test('Provide a callback for tracking refresh progress', async () => { - assert.deepEqual(proposed.environment.onRefreshProgress, onRefreshProgress); + teardown(() => { + // Verify each API method sends telemetry regarding who called the API. + extensions.verifyAll(); }); - test('Provide a callback which is called when execution details changes', async () => { - assert.deepEqual(onDidExecutionEvent, proposed.environment.onDidChangeExecutionDetails); + test('Provide an event to track when active environment details change', async () => { + const events: ActiveEnvironmentIdChangeEvent[] = []; + proposed.environment.onDidChangeActiveEnvironmentId((e) => { + events.push(e); + }); + reportActiveInterpreterChanged({ path: 'path/to/environment', resource: undefined }); + await sleep(1); + assert.deepEqual(events, [ + { id: normCasePath('path/to/environment'), path: 'path/to/environment', resource: undefined }, + ]); }); - test('getExecutionDetails: No resource', async () => { + test('getActiveEnvironmentId: No resource', () => { const pythonPath = 'this/is/a/test/path'; - interpreterService - .setup((c) => c.getActiveInterpreter(undefined)) - .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); - const actual = await proposed.environment.getExecutionDetails(); - assert.deepEqual(actual, { execCommand: [pythonPath] }); + configService + .setup((c) => c.getSettings(undefined)) + .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); + const actual = proposed.environment.getActiveEnvironmentId(); + assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath }); }); - test('getExecutionDetails: With resource', async () => { - const resource = Uri.file(__filename); - const pythonPath = 'this/is/a/test/path'; - interpreterService - .setup((c) => c.getActiveInterpreter(resource)) - .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); - const actual = await proposed.environment.getExecutionDetails(resource); - assert.deepEqual(actual, { execCommand: [pythonPath] }); + test('getActiveEnvironmentId: default python', () => { + const pythonPath = 'python'; + configService + .setup((c) => c.getSettings(undefined)) + .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); + const actual = proposed.environment.getActiveEnvironmentId(); + assert.deepEqual(actual, { id: 'DEFAULT_PYTHON', path: pythonPath }); }); - test('getActiveInterpreterPath: No resource', async () => { + test('getActiveEnvironmentId: With resource', () => { const pythonPath = 'this/is/a/test/path'; - interpreterService - .setup((c) => c.getActiveInterpreter(undefined)) - .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); - const actual = await proposed.environment.getActiveEnvironmentPath(); - assert.deepEqual(actual, { path: pythonPath, pathType: 'interpreterPath' }); - }); - test('getActiveInterpreterPath: With resource', async () => { const resource = Uri.file(__filename); - const pythonPath = 'this/is/a/test/path'; - interpreterService - .setup((c) => c.getActiveInterpreter(resource)) - .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); - const actual = await proposed.environment.getActiveEnvironmentPath(resource); - assert.deepEqual(actual, { path: pythonPath, pathType: 'interpreterPath' }); + configService + .setup((c) => c.getSettings(resource)) + .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); + const actual = proposed.environment.getActiveEnvironmentId(resource); + assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath }); }); - test('getInterpreterDetails: no discovered python', async () => { - discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - discoverAPI.setup((p) => p.resolveEnv(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + test('resolveEnvironment: invalid environment (when passed as string)', async () => { + const pythonPath = 'this/is/a/test/path'; + discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(undefined)); - const pythonPath = 'this/is/a/test/path (without cache)'; - const actual = await proposed.environment.getEnvironmentDetails(pythonPath); + const actual = await proposed.environment.resolveEnvironment(pythonPath); expect(actual).to.be.equal(undefined); }); - test('getInterpreterDetails: no discovered python (with cache)', async () => { - discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - discoverAPI.setup((p) => p.resolveEnv(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); - + test('resolveEnvironment: valid environment (when passed as string)', async () => { const pythonPath = 'this/is/a/test/path'; - const actual = await proposed.environment.getEnvironmentDetails(pythonPath, { useCache: true }); - expect(actual).to.be.equal(undefined); + const env = buildEnvInfo({ + executable: pythonPath, + version: { + major: 3, + minor: 9, + micro: 0, + }, + kind: PythonEnvKind.System, + arch: Architecture.x64, + sysPrefix: 'prefix/path', + searchLocation: Uri.file('path/to/project'), + }); + discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); + + const actual = await proposed.environment.resolveEnvironment(pythonPath); + assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); }); - test('getInterpreterDetails: without cache', async () => { + test('resolveEnvironment: valid environment (when passed as environment)', async () => { const pythonPath = 'this/is/a/test/path'; - - const expected: EnvironmentDetails = { - interpreterPath: pythonPath, - version: ['3', '9', '0'], - environmentType: [PythonEnvKind.System], - metadata: { - sysPrefix: 'prefix/path', - bitness: Architecture.x64, - project: Uri.file('path/to/project'), + const env = buildEnvInfo({ + executable: pythonPath, + version: { + major: 3, + minor: 9, + micro: 0, }, - envFolderPath: undefined, - }; + kind: PythonEnvKind.System, + arch: Architecture.x64, + sysPrefix: 'prefix/path', + searchLocation: Uri.file('path/to/project'), + }); + const partialEnv = buildEnvInfo({ + executable: pythonPath, + kind: PythonEnvKind.System, + sysPrefix: 'prefix/path', + searchLocation: Uri.file('path/to/project'), + }); + discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); + + const actual = await proposed.environment.resolveEnvironment(convertCompleteEnvInfo(partialEnv)); + assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); + }); + test('environments: no pythons found', () => { discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - discoverAPI - .setup((p) => p.resolveEnv(pythonPath)) - .returns(() => - Promise.resolve( - buildEnvInfo({ - executable: pythonPath, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - sysPrefix: 'prefix/path', - searchLocation: Uri.file('path/to/project'), - }), - ), - ); - - const actual = await proposed.environment.getEnvironmentDetails(pythonPath, { useCache: false }); - expect(actual).to.be.deep.equal(expected); + const actual = proposed.environment.all; + expect(actual).to.be.deep.equal([]); }); - test('getInterpreterDetails: from cache', async () => { - const pythonPath = 'this/is/a/test/path'; + test('environments: python found', async () => { + const envs = [ + { + executable: { + filename: 'this/is/a/test/python/path1', + ctime: 1, + mtime: 2, + sysPrefix: 'prefix/path', + }, + version: { + major: 3, + minor: 9, + micro: 0, + }, + kind: PythonEnvKind.System, + arch: Architecture.x64, + name: '', + location: '', + source: [PythonEnvSource.PathEnvVar], + distro: { + org: '', + }, + }, + { + executable: { + filename: 'this/is/a/test/python/path2', + ctime: 1, + mtime: 2, + sysPrefix: 'prefix/path', + }, + version: { + major: 3, + minor: -1, + micro: -1, + }, + kind: PythonEnvKind.Venv, + arch: Architecture.x64, + name: '', + location: '', + source: [PythonEnvSource.PathEnvVar], + distro: { + org: '', + }, + }, + ]; + discoverAPI.setup((d) => d.getEnvs()).returns(() => envs); + const actual = proposed.environment.all; + const actualEnvs = actual?.map((a) => (a as EnvironmentReference).internal); + assert.deepEqual( + actualEnvs?.sort((a, b) => a.id.localeCompare(b.id)), + envs.map((e) => convertEnvInfo(e)).sort((a, b) => a.id.localeCompare(b.id)), + ); + }); - const expected: EnvironmentDetails = { - interpreterPath: pythonPath, - version: ['3', '9', '0'], - environmentType: [PythonEnvKind.System], - metadata: { + test('Provide an event to track when list of environments change', async () => { + let events: EnvironmentsChangeEvent[] = []; + let eventValues: EnvironmentsChangeEvent[] = []; + let expectedEvents: EnvironmentsChangeEvent[] = []; + proposed.environment.onDidChangeEnvironments((e) => { + events.push(e); + }); + const envs = [ + buildEnvInfo({ + executable: 'pythonPath', + kind: PythonEnvKind.System, sysPrefix: 'prefix/path', - bitness: Architecture.x64, - project: undefined, + searchLocation: Uri.file('path/to/project'), + }), + { + executable: { + filename: 'this/is/a/test/python/path1', + ctime: 1, + mtime: 2, + sysPrefix: 'prefix/path', + }, + version: { + major: 3, + minor: 9, + micro: 0, + }, + kind: PythonEnvKind.System, + arch: Architecture.x64, + name: '', + location: '', + source: [PythonEnvSource.PathEnvVar], + distro: { + org: '', + }, }, - envFolderPath: undefined, - }; - - discoverAPI - .setup((d) => d.getEnvs()) - .returns(() => [ - { - executable: { - filename: pythonPath, - ctime: 1, - mtime: 2, - sysPrefix: 'prefix/path', - }, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - name: '', - location: '', - source: [PythonEnvSource.PathEnvVar], - distro: { - org: '', - }, + { + executable: { + filename: 'this/is/a/test/python/path2', + ctime: 1, + mtime: 2, + sysPrefix: 'prefix/path', }, - ]); - discoverAPI - .setup((p) => p.resolveEnv(pythonPath)) - .returns(() => - Promise.resolve( - buildEnvInfo({ - executable: pythonPath, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - sysPrefix: 'prefix/path', - }), - ), - ); - - const actual = await proposed.environment.getEnvironmentDetails(pythonPath, { useCache: true }); - expect(actual).to.be.deep.equal(expected); + version: { + major: 3, + minor: 10, + micro: 0, + }, + kind: PythonEnvKind.Venv, + arch: Architecture.x64, + name: '', + location: '', + source: [PythonEnvSource.PathEnvVar], + distro: { + org: '', + }, + }, + ]; + + // Now fire and verify events. Note the event value holds the reference to an environment, so may itself + // change when the environment is altered. So it's important to verify them as soon as they're received. + + // Add events + onDidChangeEnvironments.fire({ old: undefined, new: envs[0] }); + expectedEvents.push({ env: convertEnvInfo(envs[0]), type: 'add' }); + onDidChangeEnvironments.fire({ old: undefined, new: envs[1] }); + expectedEvents.push({ env: convertEnvInfo(envs[1]), type: 'add' }); + onDidChangeEnvironments.fire({ old: undefined, new: envs[2] }); + expectedEvents.push({ env: convertEnvInfo(envs[2]), type: 'add' }); + eventValues = events.map((e) => ({ env: (e.env as EnvironmentReference).internal, type: e.type })); + assert.deepEqual(eventValues, expectedEvents); + + // Update events + events = []; + expectedEvents = []; + const updatedEnv = cloneDeep(envs[0]); + updatedEnv.arch = Architecture.x86; + onDidChangeEnvironments.fire({ old: envs[0], new: updatedEnv }); + expectedEvents.push({ env: convertEnvInfo(updatedEnv), type: 'update' }); + eventValues = events.map((e) => ({ env: (e.env as EnvironmentReference).internal, type: e.type })); + assert.deepEqual(eventValues, expectedEvents); + + // Remove events + events = []; + expectedEvents = []; + onDidChangeEnvironments.fire({ old: envs[2], new: undefined }); + expectedEvents.push({ env: convertEnvInfo(envs[2]), type: 'remove' }); + eventValues = events.map((e) => ({ env: (e.env as EnvironmentReference).internal, type: e.type })); + assert.deepEqual(eventValues, expectedEvents); }); - test('getInterpreterDetails: cache miss', async () => { - const pythonPath = 'this/is/a/test/path'; + test('updateActiveEnvironmentId: no resource', async () => { + interpreterPathService + .setup((i) => i.update(undefined, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); - const expected: EnvironmentDetails = { - interpreterPath: pythonPath, - version: ['3', '9', '0'], - environmentType: [PythonEnvKind.System], - metadata: { - sysPrefix: 'prefix/path', - bitness: Architecture.x64, - project: undefined, - }, - envFolderPath: undefined, - }; + await proposed.environment.updateActiveEnvironmentId('this/is/a/test/python/path'); - // Force this API to return empty to cause a cache miss. - discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - discoverAPI - .setup((p) => p.resolveEnv(pythonPath)) - .returns(() => - Promise.resolve( - buildEnvInfo({ - executable: pythonPath, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - sysPrefix: 'prefix/path', - }), - ), - ); - - const actual = await proposed.environment.getEnvironmentDetails(pythonPath, { useCache: true }); - expect(actual).to.be.deep.equal(expected); + interpreterPathService.verifyAll(); }); - test('getInterpreterPaths: no pythons found', async () => { - discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - const actual = await proposed.environment.getEnvironmentPaths(); - expect(actual).to.be.deep.equal([]); - }); + test('updateActiveEnvironmentId: passed as Environment', async () => { + interpreterPathService + .setup((i) => i.update(undefined, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); - test('getInterpreterPaths: python found', async () => { - discoverAPI - .setup((d) => d.getEnvs()) - .returns(() => [ - { - executable: { - filename: 'this/is/a/test/python/path1', - ctime: 1, - mtime: 2, - sysPrefix: 'prefix/path', - }, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - name: '', - location: '', - source: [PythonEnvSource.PathEnvVar], - distro: { - org: '', - }, - }, - { - executable: { - filename: 'this/is/a/test/python/path2', - ctime: 1, - mtime: 2, - sysPrefix: 'prefix/path', - }, - version: { - major: 3, - minor: 10, - micro: 0, - }, - kind: PythonEnvKind.Venv, - arch: Architecture.x64, - name: '', - location: '', - source: [PythonEnvSource.PathEnvVar], - distro: { - org: '', - }, - }, - ]); - const actual = await proposed.environment.getEnvironmentPaths(); - expect(actual?.map((a) => a.path)).to.be.deep.equal([ - 'this/is/a/test/python/path1', - 'this/is/a/test/python/path2', - ]); + await proposed.environment.updateActiveEnvironmentId({ + id: normCasePath('this/is/a/test/python/path'), + path: 'this/is/a/test/python/path', + }); + + interpreterPathService.verifyAll(); }); - test('setActiveInterpreter: no resource', async () => { + test('updateActiveEnvironmentId: with uri', async () => { + const uri = Uri.parse('a'); interpreterPathService - .setup((i) => i.update(undefined, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) + .setup((i) => i.update(uri, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environment.setActiveEnvironment('this/is/a/test/python/path'); + await proposed.environment.updateActiveEnvironmentId('this/is/a/test/python/path', uri); interpreterPathService.verifyAll(); }); - test('setActiveInterpreter: with resource', async () => { - const resource = Uri.parse('a'); + + test('updateActiveEnvironmentId: with workspace folder', async () => { + const uri = Uri.parse('a'); interpreterPathService - .setup((i) => i.update(resource, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) + .setup((i) => i.update(uri, ConfigurationTarget.WorkspaceFolder, 'this/is/a/test/python/path')) .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); + const workspace: WorkspaceFolder = { + uri, + name: '', + index: 0, + }; - await proposed.environment.setActiveEnvironment('this/is/a/test/python/path', resource); + await proposed.environment.updateActiveEnvironmentId('this/is/a/test/python/path', workspace); interpreterPathService.verifyAll(); }); - test('refreshInterpreters: common scenario', async () => { + test('refreshInterpreters: default', async () => { discoverAPI - .setup((d) => d.triggerRefresh(undefined, undefined)) + .setup((d) => d.triggerRefresh(undefined, typemoq.It.isValue({ ifNotTriggerredAlready: true }))) .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - discoverAPI - .setup((d) => d.getEnvs()) - .returns(() => [ - { - executable: { - filename: 'this/is/a/test/python/path1', - ctime: 1, - mtime: 2, - sysPrefix: 'prefix/path', - }, - version: { - major: 3, - minor: 9, - micro: 0, - }, - kind: PythonEnvKind.System, - arch: Architecture.x64, - name: '', - location: 'this/is/a/test/python/path1/folder', - source: [PythonEnvSource.PathEnvVar], - distro: { - org: '', - }, - }, - { - executable: { - filename: 'this/is/a/test/python/path2', - ctime: 1, - mtime: 2, - sysPrefix: 'prefix/path', - }, - version: { - major: 3, - minor: 10, - micro: 0, - }, - kind: PythonEnvKind.Venv, - arch: Architecture.x64, - name: '', - location: '', - source: [PythonEnvSource.PathEnvVar], - distro: { - org: '', - }, - }, - ]); - const actual = await proposed.environment.refreshEnvironment(); - expect(actual).to.be.deep.equal([ - { path: 'this/is/a/test/python/path1/folder', pathType: 'envFolderPath' }, - { path: 'this/is/a/test/python/path2', pathType: 'interpreterPath' }, - ]); + await proposed.environment.refreshEnvironments(); + discoverAPI.verifyAll(); }); - test('getRefreshPromise: common scenario', () => { - const expected = Promise.resolve(); + test('refreshInterpreters: when forcing a refresh', async () => { discoverAPI - .setup((d) => d.getRefreshPromise(typemoq.It.isValue({ stage: ProgressReportStage.allPathsDiscovered }))) - .returns(() => expected); - const actual = proposed.environment.getRefreshPromise({ stage: ProgressReportStage.allPathsDiscovered }); - - // We are comparing instances here, they should be the same instance. - // So '==' is ok here. - // eslint-disable-next-line eqeqeq - expect(actual == expected).is.equal(true); + .setup((d) => d.triggerRefresh(undefined, typemoq.It.isValue({ ifNotTriggerredAlready: false }))) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + await proposed.environment.refreshEnvironments({ forceRefresh: true }); + + discoverAPI.verifyAll(); }); }); diff --git a/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts index 7bb70bead0a9..90dcb8345732 100644 --- a/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts @@ -8,7 +8,6 @@ import * as sinon from 'sinon'; import { EventEmitter, Uri } from 'vscode'; import { FileChangeType } from '../../../../../client/common/platform/fileSystemWatcher'; import { createDeferred, createDeferredFromPromise, sleep } from '../../../../../client/common/utils/async'; -import * as proposedApi from '../../../../../client/proposedApi'; import { PythonEnvInfo, PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info'; import { buildEnvInfo } from '../../../../../client/pythonEnvironments/base/info/env'; import { @@ -31,7 +30,6 @@ import { assertEnvEqual, assertEnvsEqual } from '../envTestUtils'; suite('Python envs locator - Environments Collection', async () => { let collectionService: EnvsCollectionService; let storage: PythonEnvInfo[]; - let reportInterpretersChangedStub: sinon.SinonStub; const updatedName = 'updatedName'; @@ -129,7 +127,6 @@ suite('Python envs locator - Environments Collection', async () => { }, }); collectionService = new EnvsCollectionService(cache, parentLocator); - reportInterpretersChangedStub = sinon.stub(proposedApi, 'reportInterpretersChanged'); }); teardown(() => { @@ -377,7 +374,6 @@ suite('Python envs locator - Environments Collection', async () => { collectionService = new EnvsCollectionService(cache, parentLocator); const resolved = await collectionService.resolveEnv(env.executable.filename); assertEnvEqual(resolved, env); - sinon.assert.calledOnce(reportInterpretersChangedStub); }); test('resolveEnv() uses underlying locator if cache does not have up to date info for env', async () => { @@ -406,7 +402,6 @@ suite('Python envs locator - Environments Collection', async () => { collectionService = new EnvsCollectionService(cache, parentLocator); const resolved = await collectionService.resolveEnv(env.executable.filename); assertEnvEqual(resolved, resolvedViaLocator); - sinon.assert.calledOnce(reportInterpretersChangedStub); }); test('resolveEnv() uses underlying locator if cache does not have complete info for env', async () => { @@ -429,22 +424,6 @@ suite('Python envs locator - Environments Collection', async () => { collectionService = new EnvsCollectionService(cache, parentLocator); const resolved = await collectionService.resolveEnv(env.executable.filename); assertEnvEqual(resolved, resolvedViaLocator); - - const eventData = [ - { - path: path.join(TEST_LAYOUT_ROOT, 'doesNotExist'), - type: 'remove', - }, - - { - path: 'Resolved via locator', - type: 'add', - }, - ]; - eventData.forEach((d) => { - sinon.assert.calledWithExactly(reportInterpretersChangedStub, [d]); - }); - sinon.assert.callCount(reportInterpretersChangedStub, eventData.length); }); test('resolveEnv() adds env to cache after resolving using downstream locator', async () => { @@ -468,9 +447,6 @@ suite('Python envs locator - Environments Collection', async () => { const envs = collectionService.getEnvs(); expect(resolved?.hasLatestInfo).to.equal(true); assertEnvsEqual(envs, [resolved]); - sinon.assert.calledOnceWithExactly(reportInterpretersChangedStub, [ - { path: resolved?.executable.filename, type: 'add' }, - ]); }); test('Ensure events from downstream locators do not trigger new refreshes if a refresh is already scheduled', async () => { @@ -523,7 +499,5 @@ suite('Python envs locator - Environments Collection', async () => { events.sort((a, b) => (a.type && b.type ? a.type?.localeCompare(b.type) : 0)), downstreamEvents.sort((a, b) => (a.type && b.type ? a.type?.localeCompare(b.type) : 0)), ); - - sinon.assert.notCalled(reportInterpretersChangedStub); }); });