From 06cb7640437c93ad077763ef97da42f19457d27f Mon Sep 17 00:00:00 2001 From: Kartik Raj Date: Thu, 16 Mar 2023 17:03:30 -0700 Subject: [PATCH 1/2] Finalize Python Environment API --- src/client/api.ts | 4 + src/client/apiTypes.ts | 300 +++++++++++++- src/client/environmentApi.ts | 369 ++++++++++++++++++ src/client/extension.ts | 9 +- src/client/interpreter/interpreterService.ts | 2 +- src/client/proposedApi.ts | 369 +----------------- src/client/proposedApiTypes.ts | 298 +------------- ...it.test.ts => environmentApi.unit.test.ts} | 6 +- .../interpreterService.unit.test.ts | 2 +- 9 files changed, 687 insertions(+), 672 deletions(-) create mode 100644 src/client/environmentApi.ts rename src/test/{proposedApi.unit.test.ts => environmentApi.unit.test.ts} (99%) diff --git a/src/client/api.ts b/src/client/api.ts index 96e848184f36..aaec02eee6da 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -18,11 +18,14 @@ import { IInterpreterService } from './interpreter/contracts'; import { IServiceContainer, IServiceManager } from './ioc/types'; import { JupyterExtensionIntegration } from './jupyter/jupyterIntegration'; import { traceError } from './logging'; +import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { buildEnvironmentApi } from './environmentApi'; export function buildApi( ready: Promise, serviceManager: IServiceManager, serviceContainer: IServiceContainer, + discoveryApi: IDiscoveryAPI, ): IExtensionApi { const configurationService = serviceContainer.get(IConfigurationService); const interpreterService = serviceContainer.get(IInterpreterService); @@ -103,6 +106,7 @@ export function buildApi( start: (client: BaseLanguageClient): Promise => client.start(), stop: (client: BaseLanguageClient): Promise => client.stop(), }, + environments: buildEnvironmentApi(discoveryApi, serviceContainer), }; // In test environment return the DI Container. diff --git a/src/client/apiTypes.ts b/src/client/apiTypes.ts index a10fd2dccb96..3a3b56a8c54e 100644 --- a/src/client/apiTypes.ts +++ b/src/client/apiTypes.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Event, Uri } from 'vscode'; -import { Resource } from './common/types'; +import { CancellationToken, Event, Uri, WorkspaceFolder } from 'vscode'; import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types'; /* @@ -85,4 +84,301 @@ export interface IExtensionApi { */ registerRemoteServerProvider(serverProvider: IJupyterUriProvider): void; }; + + /** + * These APIs provide a way for extensions to work with by python environments available in the user's machine + * as found by the Python extension. See + * https://github.com/microsoft/vscode-python/wiki/Python-Environment-APIs for usage examples and more. + */ + readonly environments: { + /** + * Returns the environment configured by user in settings. Note that this can be an invalid environment, use + * {@link resolveEnvironment} to get full details. + * @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. + */ + getActiveEnvironmentPath(resource?: Resource): EnvironmentPath; + /** + * Sets the active environment path for the python extension for the resource. Configuration target will always + * be the workspace folder. + * @param environment : If string, it represents the full path to environment folder or python executable + * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. + * @param resource : [optional] File or workspace to scope to a particular workspace folder. + */ + updateActiveEnvironmentPath( + environment: string | EnvironmentPath | Environment, + resource?: Resource, + ): Promise; + /** + * This event is triggered when the active environment setting changes. + */ + readonly onDidChangeActiveEnvironmentPath: Event; + /** + * Carries environments known to 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. + * + * Only reports environments in the current workspace. + */ + readonly known: 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 : If string, it represents the full path to environment folder or python executable + * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. + */ + resolveEnvironment( + environment: Environment | EnvironmentPath | string, + ): Promise; + /** + * Returns the environment variables used by the extension for a resource, which includes the custom + * variables configured by user in `.env` files. + * @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. + */ + getEnvironmentVariables(resource?: Resource): EnvironmentVariables; + /** + * This event is fired when the environment variables for a resource change. Note it's currently not + * possible to detect if environment variables in the system change, so this only fires if custom + * environment variables are updated in `.env` files. + */ + readonly onDidEnvironmentVariablesChange: Event; + }; } + +export type RefreshOptions = { + /** + * When `true`, 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 = EnvironmentPath & { + /** + * 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: WorkspaceFolder | undefined; + } + | undefined; + /** + * Carries Python version information known at this moment, carries `undefined` for envs without python. + */ + readonly version: + | (VersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string | undefined; + }) + | 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, carries `undefined` for envs without python. + */ + readonly version: + | (ResolvedVersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string; + }) + | undefined; +}; + +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 ActiveEnvironmentPathChangeEvent = EnvironmentPath & { + /** + * 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 EnvironmentPath = { + /** + * 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; +}; + +/** + * A record containing readonly keys. + */ +export type EnvironmentVariables = { readonly [key: string]: string | undefined }; + +export type EnvironmentVariablesChangeEvent = { + /** + * Workspace folder the environment variables changed for. + */ + readonly resource: WorkspaceFolder | undefined; + /** + * Updated value of environment variables. + */ + readonly env: EnvironmentVariables; +}; diff --git a/src/client/environmentApi.ts b/src/client/environmentApi.ts new file mode 100644 index 000000000000..533d187ca520 --- /dev/null +++ b/src/client/environmentApi.ts @@ -0,0 +1,369 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ConfigurationTarget, EventEmitter, Uri, workspace, 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, PythonEnvKind, PythonEnvType } from './pythonEnvironments/base/info'; +import { getEnvPath } from './pythonEnvironments/base/info/env'; +import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { IPythonExecutionFactory } from './common/process/types'; +import { traceError, traceVerbose } from './logging'; +import { isParentPath, normCasePath } from './common/platform/fs-paths'; +import { sendTelemetryEvent } from './telemetry'; +import { EventName } from './telemetry/constants'; +import { reportActiveInterpreterChangedDeprecated, reportInterpretersChanged } from './deprecatedProposedApi'; +import { IEnvironmentVariablesProvider } from './common/variables/types'; +import { getWorkspaceFolder, getWorkspaceFolders } from './common/vscodeApis/workspaceApis'; +import { + ActiveEnvironmentPathChangeEvent, + Environment, + EnvironmentPath, + EnvironmentsChangeEvent, + EnvironmentTools, + EnvironmentType, + EnvironmentVariablesChangeEvent, + IExtensionApi, + RefreshOptions, + ResolvedEnvironment, + Resource, +} from './apiTypes'; + +type ActiveEnvironmentChangeEvent = { + resource: WorkspaceFolder | undefined; + path: string; +}; + +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 }); +} + +const onEnvironmentsChanged = new EventEmitter(); +const onEnvironmentVariablesChanged = new EventEmitter(); +const environmentsReference = new Map(); + +/** + * Make all properties in T mutable. + */ +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; +} + +function filterUsingVSCodeContext(e: PythonEnvInfo) { + const folders = getWorkspaceFolders(); + if (e.searchLocation) { + // Only return local environments that are in the currently opened workspace folders. + const envFolderUri = e.searchLocation; + if (folders) { + return folders.some((folder) => isParentPath(envFolderUri.fsPath, folder.uri.fsPath)); + } + return false; + } + return true; +} + +export function buildEnvironmentApi( + discoveryApi: IDiscoveryAPI, + serviceContainer: IServiceContainer, +): IExtensionApi['environments'] { + const interpreterPathService = serviceContainer.get(IInterpreterPathService); + const configService = serviceContainer.get(IConfigurationService); + const disposables = serviceContainer.get(IDisposableRegistry); + const extensions = serviceContainer.get(IExtensions); + const envVarsProvider = serviceContainer.get(IEnvironmentVariablesProvider); + function sendApiTelemetry(apiName: string, args?: unknown) { + extensions + .determineExtensionFromCallStack() + .then((info) => { + sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { + apiName, + extensionId: info.extensionId, + }); + traceVerbose(`Extension ${info.extensionId} accessed ${apiName} with args: ${JSON.stringify(args)}`); + }) + .ignoreErrors(); + } + disposables.push( + discoveryApi.onChanged((e) => { + const env = e.new ?? e.old; + if (!env || !filterUsingVSCodeContext(env)) { + // Filter out environments that are not in the current workspace. + return; + } + if (e.old) { + if (e.new) { + traceVerbose('Python API env change detected', env.id, 'update'); + onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'update', + }, + ]); + } else { + traceVerbose('Python API env change detected', env.id, 'remove'); + 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) { + traceVerbose('Python API env change detected', env.id, 'add'); + onEnvironmentsChanged.fire({ type: 'add', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'add', + }, + ]); + } + }), + envVarsProvider.onDidEnvironmentVariablesChange((e) => { + onEnvironmentVariablesChanged.fire({ + resource: getWorkspaceFolder(e), + env: envVarsProvider.getEnvironmentVariablesSync(e), + }); + }), + onEnvironmentsChanged, + onEnvironmentVariablesChanged, + ); + + const environmentApi: IExtensionApi['environments'] = { + getEnvironmentVariables: (resource?: Resource) => { + sendApiTelemetry('getEnvironmentVariables'); + resource = resource && 'uri' in resource ? resource.uri : resource; + return envVarsProvider.getEnvironmentVariablesSync(resource); + }, + get onDidEnvironmentVariablesChange() { + sendApiTelemetry('onDidEnvironmentVariablesChange'); + return onEnvironmentVariablesChanged.event; + }, + getActiveEnvironmentPath(resource?: Resource) { + sendApiTelemetry('getActiveEnvironmentPath'); + 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, + }; + }, + updateActiveEnvironmentPath(env: Environment | EnvironmentPath | string, resource?: Resource): Promise { + sendApiTelemetry('updateActiveEnvironmentPath'); + const path = typeof env !== 'string' ? env.path : env; + resource = resource && 'uri' in resource ? resource.uri : resource; + return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); + }, + get onDidChangeActiveEnvironmentPath() { + sendApiTelemetry('onDidChangeActiveEnvironmentPath'); + return onDidActiveInterpreterChangedEvent.event; + }, + resolveEnvironment: async (env: Environment | EnvironmentPath | string) => { + if (!workspace.isTrusted) { + throw new Error('Not allowed to resolve environment in an untrusted workspace'); + } + 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; + } + sendApiTelemetry('resolveEnvironment', env); + return resolveEnvironment(path, discoveryApi); + }, + get known(): Environment[] { + sendApiTelemetry('known'); + return discoveryApi + .getEnvs() + .filter((e) => filterUsingVSCodeContext(e)) + .map((e) => convertEnvInfoAndGetReference(e)); + }, + async refreshEnvironments(options?: RefreshOptions) { + if (!workspace.isTrusted) { + traceError('Not allowed to refresh environments in an untrusted workspace'); + return; + } + await discoveryApi.triggerRefresh(undefined, { + ifNotTriggerredAlready: !options?.forceRefresh, + }); + sendApiTelemetry('refreshEnvironments'); + }, + get onDidChangeEnvironments() { + sendApiTelemetry('onDidChangeEnvironments'); + return onEnvironmentsChanged.event; + }, + }; + return environmentApi; +} + +async function resolveEnvironment(path: string, discoveryApi: IDiscoveryAPI): Promise { + const env = await discoveryApi.resolveEnv(path); + if (!env) { + return undefined; + } + const resolvedEnv = getEnvReference(convertCompleteEnvInfo(env)) as ResolvedEnvironment; + if (resolvedEnv.version?.major === -1 || resolvedEnv.version?.minor === -1 || resolvedEnv.version?.micro === -1) { + traceError(`Invalid version for ${path}: ${JSON.stringify(env)}`); + } + return resolvedEnv; +} + +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: env.id!, + executable: { + uri: env.executable.filename === 'python' ? undefined : Uri.file(env.executable.filename), + bitness: convertBitness(env.arch), + sysPrefix: env.executable.sysPrefix, + }, + environment: env.type + ? { + type: convertEnvType(env.type), + name: env.name === '' ? undefined : env.name, + folderUri: Uri.file(env.location), + workspaceFolder: getWorkspaceFolder(env.searchLocation), + } + : undefined, + version: env.executable.filename === 'python' ? undefined : (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.version?.sysVersion === '') { + convertedEnv.version.sysVersion = 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/extension.ts b/src/client/extension.ts index 67710b73c8b0..5fcb63e2d322 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -42,10 +42,10 @@ import { IStartupDurations } from './types'; import { runAfterActivation } from './common/utils/runAfterActivation'; import { IInterpreterService } from './interpreter/contracts'; import { IExtensionApi } from './apiTypes'; -import { buildProposedApi } from './proposedApi'; import { WorkspaceService } from './common/application/workspace'; import { disposeAll } from './common/utils/resourceLifecycle'; import { ProposedExtensionAPI } from './proposedApiTypes'; +import { buildProposedApi } from './proposedApi'; durations.codeLoadingTime = stopWatch.elapsedTime; @@ -156,7 +156,12 @@ async function activateUnsafe( runAfterActivation(); }); - const api = buildApi(activationPromise, ext.legacyIOC.serviceManager, ext.legacyIOC.serviceContainer); + const api = buildApi( + activationPromise, + ext.legacyIOC.serviceManager, + ext.legacyIOC.serviceContainer, + components.pythonEnvs, + ); const proposedApi = buildProposedApi(components.pythonEnvs, ext.legacyIOC.serviceContainer); return [{ ...api, ...proposedApi }, activationPromise, ext.legacyIOC.serviceContainer]; } diff --git a/src/client/interpreter/interpreterService.ts b/src/client/interpreter/interpreterService.ts index 2fee9aaec22e..a39be064f5ad 100644 --- a/src/client/interpreter/interpreterService.ts +++ b/src/client/interpreter/interpreterService.ts @@ -31,7 +31,7 @@ import { } from './contracts'; import { traceError, traceLog } from '../logging'; import { Commands, PYTHON_LANGUAGE } from '../common/constants'; -import { reportActiveInterpreterChanged } from '../proposedApi'; +import { reportActiveInterpreterChanged } from '../environmentApi'; import { IPythonExecutionFactory } from '../common/process/types'; import { Interpreters } from '../common/utils/localize'; import { sendTelemetryEvent } from '../telemetry'; diff --git a/src/client/proposedApi.ts b/src/client/proposedApi.ts index 70f4b96a44aa..22d53b0201ef 100644 --- a/src/client/proposedApi.ts +++ b/src/client/proposedApi.ts @@ -2,190 +2,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ConfigurationTarget, EventEmitter, Uri, workspace, 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 { - ActiveEnvironmentPathChangeEvent, - Environment, - EnvironmentsChangeEvent, - ProposedExtensionAPI, - ResolvedEnvironment, - RefreshOptions, - Resource, - EnvironmentType, - EnvironmentTools, - EnvironmentPath, - EnvironmentVariablesChangeEvent, -} from './proposedApiTypes'; -import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from './pythonEnvironments/base/info'; -import { getEnvPath } from './pythonEnvironments/base/info/env'; +import { ProposedExtensionAPI } from './proposedApiTypes'; import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; -import { IPythonExecutionFactory } from './common/process/types'; -import { traceError, traceVerbose } from './logging'; -import { isParentPath, normCasePath } from './common/platform/fs-paths'; -import { sendTelemetryEvent } from './telemetry'; -import { EventName } from './telemetry/constants'; -import { - buildDeprecatedProposedApi, - reportActiveInterpreterChangedDeprecated, - reportInterpretersChanged, -} from './deprecatedProposedApi'; +import { buildDeprecatedProposedApi } from './deprecatedProposedApi'; import { DeprecatedProposedAPI } from './deprecatedProposedApiTypes'; -import { IEnvironmentVariablesProvider } from './common/variables/types'; -import { getWorkspaceFolder, getWorkspaceFolders } from './common/vscodeApis/workspaceApis'; - -type ActiveEnvironmentChangeEvent = { - resource: WorkspaceFolder | undefined; - path: string; -}; - -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 }); -} - -const onEnvironmentsChanged = new EventEmitter(); -const onEnvironmentVariablesChanged = new EventEmitter(); -const environmentsReference = new Map(); - -/** - * Make all properties in T mutable. - */ -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; -} - -function filterUsingVSCodeContext(e: PythonEnvInfo) { - const folders = getWorkspaceFolders(); - if (e.searchLocation) { - // Only return local environments that are in the currently opened workspace folders. - const envFolderUri = e.searchLocation; - if (folders) { - return folders.some((folder) => isParentPath(envFolderUri.fsPath, folder.uri.fsPath)); - } - return false; - } - return true; -} export function buildProposedApi( discoveryApi: IDiscoveryAPI, serviceContainer: IServiceContainer, ): ProposedExtensionAPI { - const interpreterPathService = serviceContainer.get(IInterpreterPathService); - const configService = serviceContainer.get(IConfigurationService); - const disposables = serviceContainer.get(IDisposableRegistry); - const extensions = serviceContainer.get(IExtensions); - const envVarsProvider = serviceContainer.get(IEnvironmentVariablesProvider); - function sendApiTelemetry(apiName: string, args?: unknown) { - extensions - .determineExtensionFromCallStack() - .then((info) => { - sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { - apiName, - extensionId: info.extensionId, - }); - traceVerbose(`Extension ${info.extensionId} accessed ${apiName} with args: ${JSON.stringify(args)}`); - }) - .ignoreErrors(); - } - disposables.push( - discoveryApi.onChanged((e) => { - const env = e.new ?? e.old; - if (!env || !filterUsingVSCodeContext(env)) { - // Filter out environments that are not in the current workspace. - return; - } - if (e.old) { - if (e.new) { - traceVerbose('Python API env change detected', env.id, 'update'); - onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); - reportInterpretersChanged([ - { - path: getEnvPath(e.new.executable.filename, e.new.location).path, - type: 'update', - }, - ]); - } else { - traceVerbose('Python API env change detected', env.id, 'remove'); - 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) { - traceVerbose('Python API env change detected', env.id, 'add'); - onEnvironmentsChanged.fire({ type: 'add', env: convertEnvInfoAndGetReference(e.new) }); - reportInterpretersChanged([ - { - path: getEnvPath(e.new.executable.filename, e.new.location).path, - type: 'add', - }, - ]); - } - }), - envVarsProvider.onDidEnvironmentVariablesChange((e) => { - onEnvironmentVariablesChanged.fire({ - resource: getWorkspaceFolder(e), - env: envVarsProvider.getEnvironmentVariablesSync(e), - }); - }), - onEnvironmentsChanged, - onEnvironmentVariablesChanged, - ); - /** - * @deprecated Will be removed soon. Use {@link ProposedExtensionAPI} instead. + * @deprecated Will be removed soon. */ let deprecatedProposedApi; try { @@ -198,197 +26,6 @@ export function buildProposedApi( const proposed: ProposedExtensionAPI & DeprecatedProposedAPI = { ...deprecatedProposedApi, - environments: { - getEnvironmentVariables: (resource?: Resource) => { - sendApiTelemetry('getEnvironmentVariables'); - resource = resource && 'uri' in resource ? resource.uri : resource; - return envVarsProvider.getEnvironmentVariablesSync(resource); - }, - get onDidEnvironmentVariablesChange() { - sendApiTelemetry('onDidEnvironmentVariablesChange'); - return onEnvironmentVariablesChanged.event; - }, - getActiveEnvironmentPath(resource?: Resource) { - sendApiTelemetry('getActiveEnvironmentPath'); - 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, - }; - }, - updateActiveEnvironmentPath( - env: Environment | EnvironmentPath | string, - resource?: Resource, - ): Promise { - sendApiTelemetry('updateActiveEnvironmentPath'); - const path = typeof env !== 'string' ? env.path : env; - resource = resource && 'uri' in resource ? resource.uri : resource; - return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); - }, - get onDidChangeActiveEnvironmentPath() { - sendApiTelemetry('onDidChangeActiveEnvironmentPath'); - return onDidActiveInterpreterChangedEvent.event; - }, - resolveEnvironment: async (env: Environment | EnvironmentPath | string) => { - if (!workspace.isTrusted) { - throw new Error('Not allowed to resolve environment in an untrusted workspace'); - } - 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; - } - sendApiTelemetry('resolveEnvironment', env); - return resolveEnvironment(path, discoveryApi); - }, - get known(): Environment[] { - sendApiTelemetry('known'); - return discoveryApi - .getEnvs() - .filter((e) => filterUsingVSCodeContext(e)) - .map((e) => convertEnvInfoAndGetReference(e)); - }, - async refreshEnvironments(options?: RefreshOptions) { - if (!workspace.isTrusted) { - traceError('Not allowed to refresh environments in an untrusted workspace'); - return; - } - await discoveryApi.triggerRefresh(undefined, { - ifNotTriggerredAlready: !options?.forceRefresh, - }); - sendApiTelemetry('refreshEnvironments'); - }, - get onDidChangeEnvironments() { - sendApiTelemetry('onDidChangeEnvironments'); - return onEnvironmentsChanged.event; - }, - }, }; return proposed; } - -async function resolveEnvironment(path: string, discoveryApi: IDiscoveryAPI): Promise { - const env = await discoveryApi.resolveEnv(path); - if (!env) { - return undefined; - } - const resolvedEnv = getEnvReference(convertCompleteEnvInfo(env)) as ResolvedEnvironment; - if (resolvedEnv.version?.major === -1 || resolvedEnv.version?.minor === -1 || resolvedEnv.version?.micro === -1) { - traceError(`Invalid version for ${path}: ${JSON.stringify(env)}`); - } - return resolvedEnv; -} - -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: env.id!, - executable: { - uri: env.executable.filename === 'python' ? undefined : Uri.file(env.executable.filename), - bitness: convertBitness(env.arch), - sysPrefix: env.executable.sysPrefix, - }, - environment: env.type - ? { - type: convertEnvType(env.type), - name: env.name === '' ? undefined : env.name, - folderUri: Uri.file(env.location), - workspaceFolder: getWorkspaceFolder(env.searchLocation), - } - : undefined, - version: env.executable.filename === 'python' ? undefined : (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.version?.sysVersion === '') { - convertedEnv.version.sysVersion = 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 index a40ad3312d02..1b772b406644 100644 --- a/src/client/proposedApiTypes.ts +++ b/src/client/proposedApiTypes.ts @@ -1,300 +1,4 @@ // 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 environments: { - /** - * Returns the environment configured by user in settings. Note that this can be an invalid environment, use - * {@link resolveEnvironment} to get full details. - * @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. - */ - getActiveEnvironmentPath(resource?: Resource): EnvironmentPath; - /** - * Sets the active environment path for the python extension for the resource. Configuration target will always - * be the workspace folder. - * @param environment : If string, it represents the full path to environment folder or python executable - * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. - * @param resource : [optional] File or workspace to scope to a particular workspace folder. - */ - updateActiveEnvironmentPath( - environment: string | EnvironmentPath | Environment, - resource?: Resource, - ): Promise; - /** - * This event is triggered when the active environment setting changes. - */ - readonly onDidChangeActiveEnvironmentPath: Event; - /** - * Carries environments known to 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. - * - * Only reports environments in the current workspace. - */ - readonly known: 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 : If string, it represents the full path to environment folder or python executable - * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. - */ - resolveEnvironment( - environment: Environment | EnvironmentPath | string, - ): Promise; - /** - * Returns the environment variables used by the extension for a resource, which includes the custom - * variables configured by user in `.env` files. - * @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. - */ - getEnvironmentVariables(resource?: Resource): EnvironmentVariables; - /** - * This event is fired when the environment variables for a resource change. Note it's currently not - * possible to detect if environment variables in the system change, so this only fires if custom - * environment variables are updated in `.env` files. - */ - readonly onDidEnvironmentVariablesChange: Event; - }; -} - -export type RefreshOptions = { - /** - * When `true`, 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 = EnvironmentPath & { - /** - * 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: WorkspaceFolder | undefined; - } - | undefined; - /** - * Carries Python version information known at this moment, carries `undefined` for envs without python. - */ - readonly version: - | (VersionInfo & { - /** - * Value of `sys.version` in sys module if known at this moment. - */ - readonly sysVersion: string | undefined; - }) - | 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, carries `undefined` for envs without python. - */ - readonly version: - | (ResolvedVersionInfo & { - /** - * Value of `sys.version` in sys module if known at this moment. - */ - readonly sysVersion: string; - }) - | undefined; -}; - -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 ActiveEnvironmentPathChangeEvent = EnvironmentPath & { - /** - * 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 EnvironmentPath = { - /** - * 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; -}; - -/** - * A record containing readonly keys. - */ -export type EnvironmentVariables = { readonly [key: string]: string | undefined }; - -export type EnvironmentVariablesChangeEvent = { - /** - * Workspace folder the environment variables changed for. - */ - readonly resource: WorkspaceFolder | undefined; - /** - * Updated value of environment variables. - */ - readonly env: EnvironmentVariables; -}; +export interface ProposedExtensionAPI {} diff --git a/src/test/proposedApi.unit.test.ts b/src/test/environmentApi.unit.test.ts similarity index 99% rename from src/test/proposedApi.unit.test.ts rename to src/test/environmentApi.unit.test.ts index 750b88438d2f..d074a4750a42 100644 --- a/src/test/proposedApi.unit.test.ts +++ b/src/test/environmentApi.unit.test.ts @@ -16,12 +16,12 @@ import { } from '../client/common/types'; import { IServiceContainer } from '../client/ioc/types'; import { - buildProposedApi, + buildEnvironmentApi, convertCompleteEnvInfo, convertEnvInfo, EnvironmentReference, reportActiveInterpreterChanged, -} from '../client/proposedApi'; +} from '../client/environmentApi'; import { IDiscoveryAPI, ProgressNotificationEvent } from '../client/pythonEnvironments/base/locator'; import { buildEnvInfo } from '../client/pythonEnvironments/base/info/env'; import { sleep } from './core'; @@ -95,7 +95,7 @@ suite('Proposed Extension API', () => { discoverAPI.setup((d) => d.onProgress).returns(() => onDidChangeRefreshState.event); discoverAPI.setup((d) => d.onChanged).returns(() => onDidChangeEnvironments.event); - proposed = buildProposedApi(discoverAPI.object, serviceContainer.object); + proposed = buildEnvironmentApi(discoverAPI.object, serviceContainer.object); }); teardown(() => { diff --git a/src/test/interpreters/interpreterService.unit.test.ts b/src/test/interpreters/interpreterService.unit.test.ts index 1bbf729e53b9..d8a0ada23a6f 100644 --- a/src/test/interpreters/interpreterService.unit.test.ts +++ b/src/test/interpreters/interpreterService.unit.test.ts @@ -35,7 +35,7 @@ import { ServiceContainer } from '../../client/ioc/container'; import { ServiceManager } from '../../client/ioc/serviceManager'; import { PYTHON_PATH } from '../common'; import { MockAutoSelectionService } from '../mocks/autoSelector'; -import * as proposedApi from '../../client/proposedApi'; +import * as proposedApi from '../../client/environmentApi'; /* eslint-disable @typescript-eslint/no-explicit-any */ From 0e2048690b9e3cb867cdf5c930b0e81f973be4cd Mon Sep 17 00:00:00 2001 From: Kartik Raj Date: Thu, 16 Mar 2023 17:25:40 -0700 Subject: [PATCH 2/2] Fix test and deprecate APIs --- src/client/api.ts | 36 +++++++++++++++ src/client/apiTypes.ts | 33 -------------- src/client/deprecatedProposedApiTypes.ts | 2 +- src/test/api.functional.test.ts | 47 ++++--------------- src/test/environmentApi.unit.test.ts | 58 ++++++++++++------------ 5 files changed, 74 insertions(+), 102 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index aaec02eee6da..a2f2997c38d0 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -46,6 +46,42 @@ export function buildApi( start(client: BaseLanguageClient): Promise; stop(client: BaseLanguageClient): Promise; }; + } & { + /** + * @deprecated Use IExtensionApi.environments API instead. + * + * Return internal settings within the extension which are stored in VSCode storage + */ + settings: { + /** + * 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, + ): { + /** + * 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; + }; + }; } = { // 'ready' will propagate the exception, but we must log it here first. ready: ready.catch((ex) => { diff --git a/src/client/apiTypes.ts b/src/client/apiTypes.ts index 3a3b56a8c54e..d30a81582a7e 100644 --- a/src/client/apiTypes.ts +++ b/src/client/apiTypes.ts @@ -37,39 +37,6 @@ export interface IExtensionApi { */ getDebuggerPackagePath(): Promise; }; - /** - * Return internal settings within the extension which are stored in VSCode storage - */ - settings: { - /** - * 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, - ): { - /** - * 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; - }; - }; datascience: { /** diff --git a/src/client/deprecatedProposedApiTypes.ts b/src/client/deprecatedProposedApiTypes.ts index f2a2cbe040af..14cabe1d09ae 100644 --- a/src/client/deprecatedProposedApiTypes.ts +++ b/src/client/deprecatedProposedApiTypes.ts @@ -2,9 +2,9 @@ // 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'; +import { Resource } from './apiTypes'; export interface EnvironmentDetailsOptions { useCache: boolean; diff --git a/src/test/api.functional.test.ts b/src/test/api.functional.test.ts index 490b5d86b8b3..74293f55256c 100644 --- a/src/test/api.functional.test.ts +++ b/src/test/api.functional.test.ts @@ -6,18 +6,17 @@ import { assert, expect } from 'chai'; import * as path from 'path'; import { instance, mock, when } from 'ts-mockito'; -import * as Typemoq from 'typemoq'; -import { Event, Uri } from 'vscode'; import { buildApi } from '../client/api'; import { ConfigurationService } from '../client/common/configuration/service'; import { EXTENSION_ROOT_DIR } from '../client/common/constants'; -import { IConfigurationService } from '../client/common/types'; +import { IConfigurationService, IDisposableRegistry } from '../client/common/types'; import { IEnvironmentVariablesProvider } from '../client/common/variables/types'; import { IInterpreterService } from '../client/interpreter/contracts'; import { InterpreterService } from '../client/interpreter/interpreterService'; import { ServiceContainer } from '../client/ioc/container'; import { ServiceManager } from '../client/ioc/serviceManager'; import { IServiceContainer, IServiceManager } from '../client/ioc/types'; +import { IDiscoveryAPI } from '../client/pythonEnvironments/base/locator'; suite('Extension API', () => { const debuggerPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'lib', 'python', 'debugpy'); @@ -28,6 +27,7 @@ suite('Extension API', () => { let serviceManager: IServiceManager; let configurationService: IConfigurationService; let interpreterService: IInterpreterService; + let discoverAPI: IDiscoveryAPI; let environmentVariablesProvider: IEnvironmentVariablesProvider; setup(() => { @@ -36,6 +36,7 @@ suite('Extension API', () => { configurationService = mock(ConfigurationService); interpreterService = mock(InterpreterService); environmentVariablesProvider = mock(); + discoverAPI = mock(); when(serviceContainer.get(IConfigurationService)).thenReturn( instance(configurationService), @@ -44,42 +45,7 @@ suite('Extension API', () => { instance(environmentVariablesProvider), ); when(serviceContainer.get(IInterpreterService)).thenReturn(instance(interpreterService)); - }); - - test('Execution details settings API returns expected object if interpreter is set', async () => { - const resource = Uri.parse('a'); - when(configurationService.getSettings(resource)).thenReturn({ pythonPath: 'settingValue' } as any); - - const execDetails = buildApi( - Promise.resolve(), - instance(serviceManager), - instance(serviceContainer), - ).settings.getExecutionDetails(resource); - - assert.deepEqual(execDetails, { execCommand: ['settingValue'] }); - }); - - test('Execution details settings API returns `undefined` if interpreter is set', async () => { - const resource = Uri.parse('a'); - when(configurationService.getSettings(resource)).thenReturn({ pythonPath: '' } as any); - - const execDetails = buildApi( - Promise.resolve(), - instance(serviceManager), - instance(serviceContainer), - ).settings.getExecutionDetails(resource); - - assert.deepEqual(execDetails, { execCommand: undefined }); - }); - - test('Provide a callback which is called when interpreter setting changes', async () => { - const expectedEvent = Typemoq.Mock.ofType>().object; - when(interpreterService.onDidChangeInterpreterConfiguration).thenReturn(expectedEvent); - - const result = buildApi(Promise.resolve(), instance(serviceManager), instance(serviceContainer)).settings - .onDidChangeExecutionDetails; - - assert.deepEqual(result, expectedEvent); + when(serviceContainer.get(IDisposableRegistry)).thenReturn([]); }); test('Test debug launcher args (no-wait)', async () => { @@ -89,6 +55,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getRemoteLauncherCommand(debuggerHost, debuggerPort, waitForAttach); const expectedArgs = [ debuggerPath.fileToCommandArgumentForPythonExt(), @@ -106,6 +73,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getRemoteLauncherCommand(debuggerHost, debuggerPort, waitForAttach); const expectedArgs = [ debuggerPath.fileToCommandArgumentForPythonExt(), @@ -122,6 +90,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getDebuggerPackagePath(); assert.strictEqual(pkgPath, debuggerPath); diff --git a/src/test/environmentApi.unit.test.ts b/src/test/environmentApi.unit.test.ts index d074a4750a42..a4ea73fb6c92 100644 --- a/src/test/environmentApi.unit.test.ts +++ b/src/test/environmentApi.unit.test.ts @@ -29,17 +29,17 @@ import { PythonEnvKind, PythonEnvSource } from '../client/pythonEnvironments/bas import { Architecture } from '../client/common/utils/platform'; import { PythonEnvCollectionChangedEvent } from '../client/pythonEnvironments/base/watcher'; import { normCasePath } from '../client/common/platform/fs-paths'; -import { - ActiveEnvironmentPathChangeEvent, - EnvironmentsChangeEvent, - EnvironmentVariablesChangeEvent, - ProposedExtensionAPI, -} from '../client/proposedApiTypes'; import { IWorkspaceService } from '../client/common/application/types'; import { IEnvironmentVariablesProvider } from '../client/common/variables/types'; import * as workspaceApis from '../client/common/vscodeApis/workspaceApis'; +import { + ActiveEnvironmentPathChangeEvent, + EnvironmentVariablesChangeEvent, + EnvironmentsChangeEvent, + IExtensionApi, +} from '../client/apiTypes'; -suite('Proposed Extension API', () => { +suite('Python Environment API', () => { const workspacePath = 'path/to/workspace'; const workspaceFolder = { name: 'workspace', @@ -57,7 +57,7 @@ suite('Proposed Extension API', () => { let onDidChangeEnvironments: EventEmitter; let onDidChangeEnvironmentVariables: EventEmitter; - let proposed: ProposedExtensionAPI; + let environmentApi: IExtensionApi['environments']; setup(() => { serviceContainer = typemoq.Mock.ofType(); @@ -95,7 +95,7 @@ suite('Proposed Extension API', () => { discoverAPI.setup((d) => d.onProgress).returns(() => onDidChangeRefreshState.event); discoverAPI.setup((d) => d.onChanged).returns(() => onDidChangeEnvironments.event); - proposed = buildEnvironmentApi(discoverAPI.object, serviceContainer.object); + environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object); }); teardown(() => { @@ -109,7 +109,7 @@ suite('Proposed Extension API', () => { const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); const events: EnvironmentVariablesChangeEvent[] = []; - proposed.environments.onDidEnvironmentVariablesChange((e) => { + environmentApi.onDidEnvironmentVariablesChange((e) => { events.push(e); }); onDidChangeEnvironmentVariables.fire(resource); @@ -121,7 +121,7 @@ suite('Proposed Extension API', () => { const resource = undefined; const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(resource); + const vars = environmentApi.getEnvironmentVariables(resource); assert.deepEqual(vars, envVars); }); @@ -129,7 +129,7 @@ suite('Proposed Extension API', () => { const resource = Uri.file('x'); const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(resource); + const vars = environmentApi.getEnvironmentVariables(resource); assert.deepEqual(vars, envVars); }); @@ -138,13 +138,13 @@ suite('Proposed Extension API', () => { const folder = ({ uri: resource } as unknown) as WorkspaceFolder; const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(folder); + const vars = environmentApi.getEnvironmentVariables(folder); assert.deepEqual(vars, envVars); }); test('Provide an event to track when active environment details change', async () => { const events: ActiveEnvironmentPathChangeEvent[] = []; - proposed.environments.onDidChangeActiveEnvironmentPath((e) => { + environmentApi.onDidChangeActiveEnvironmentPath((e) => { events.push(e); }); reportActiveInterpreterChanged({ path: 'path/to/environment', resource: undefined }); @@ -159,7 +159,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(undefined)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(); + const actual = environmentApi.getActiveEnvironmentPath(); assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath, @@ -171,7 +171,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(undefined)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(); + const actual = environmentApi.getActiveEnvironmentPath(); assert.deepEqual(actual, { id: 'DEFAULT_PYTHON', path: pythonPath, @@ -184,7 +184,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(resource)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(resource); + const actual = environmentApi.getActiveEnvironmentPath(resource); assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath, @@ -195,7 +195,7 @@ suite('Proposed Extension API', () => { const pythonPath = 'this/is/a/test/path'; discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(undefined)); - const actual = await proposed.environments.resolveEnvironment(pythonPath); + const actual = await environmentApi.resolveEnvironment(pythonPath); expect(actual).to.be.equal(undefined); }); @@ -215,7 +215,7 @@ suite('Proposed Extension API', () => { }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); - const actual = await proposed.environments.resolveEnvironment(pythonPath); + const actual = await environmentApi.resolveEnvironment(pythonPath); assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); }); @@ -241,13 +241,13 @@ suite('Proposed Extension API', () => { }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); - const actual = await proposed.environments.resolveEnvironment(convertCompleteEnvInfo(partialEnv)); + const actual = await environmentApi.resolveEnvironment(convertCompleteEnvInfo(partialEnv)); assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); }); test('environments: no pythons found', () => { discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - const actual = proposed.environments.known; + const actual = environmentApi.known; expect(actual).to.be.deep.equal([]); }); @@ -325,7 +325,7 @@ suite('Proposed Extension API', () => { }, ]; discoverAPI.setup((d) => d.getEnvs()).returns(() => envs); - const actual = proposed.environments.known; + const actual = environmentApi.known; const actualEnvs = actual?.map((a) => (a as EnvironmentReference).internal); assert.deepEqual( actualEnvs?.sort((a, b) => a.id.localeCompare(b.id)), @@ -337,7 +337,7 @@ suite('Proposed Extension API', () => { let events: EnvironmentsChangeEvent[] = []; let eventValues: EnvironmentsChangeEvent[] = []; let expectedEvents: EnvironmentsChangeEvent[] = []; - proposed.environments.onDidChangeEnvironments((e) => { + environmentApi.onDidChangeEnvironments((e) => { events.push(e); }); const envs = [ @@ -431,7 +431,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path'); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path'); interpreterPathService.verifyAll(); }); @@ -442,7 +442,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath({ + await environmentApi.updateActiveEnvironmentPath({ id: normCasePath('this/is/a/test/python/path'), path: 'this/is/a/test/python/path', }); @@ -457,7 +457,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path', uri); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path', uri); interpreterPathService.verifyAll(); }); @@ -474,7 +474,7 @@ suite('Proposed Extension API', () => { index: 0, }; - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path', workspace); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path', workspace); interpreterPathService.verifyAll(); }); @@ -485,7 +485,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.refreshEnvironments(); + await environmentApi.refreshEnvironments(); discoverAPI.verifyAll(); }); @@ -496,7 +496,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.refreshEnvironments({ forceRefresh: true }); + await environmentApi.refreshEnvironments({ forceRefresh: true }); discoverAPI.verifyAll(); });