diff --git a/src/client/environmentApi.ts b/src/client/environmentApi.ts index 533d187ca520..3e846eb2772f 100644 --- a/src/client/environmentApi.ts +++ b/src/client/environmentApi.ts @@ -31,6 +31,7 @@ import { ResolvedEnvironment, Resource, } from './apiTypes'; +import { buildEnvironmentCreationApi } from './pythonEnvironments/creation/createEnvApi'; type ActiveEnvironmentChangeEvent = { resource: WorkspaceFolder | undefined; @@ -253,6 +254,7 @@ export function buildEnvironmentApi( sendApiTelemetry('onDidChangeEnvironments'); return onEnvironmentsChanged.event; }, + ...buildEnvironmentCreationApi(), }; return environmentApi; } diff --git a/src/client/proposedApiTypes.ts b/src/client/proposedApiTypes.ts index 1b772b406644..13ad5af543ec 100644 --- a/src/client/proposedApiTypes.ts +++ b/src/client/proposedApiTypes.ts @@ -1,4 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -export interface ProposedExtensionAPI {} +export interface ProposedExtensionAPI { + /** + * Top level proposed APIs should go here. + */ +} diff --git a/src/client/pythonEnvironments/creation/createEnvApi.ts b/src/client/pythonEnvironments/creation/createEnvApi.ts index cfbf81909d59..6445e0938025 100644 --- a/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -9,14 +9,15 @@ import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; import { getCreationEvents, handleCreateEnvironmentCommand } from './createEnvironment'; import { condaCreationProvider } from './provider/condaCreationProvider'; import { VenvCreationProvider } from './provider/venvCreationProvider'; +import { showInformationMessage } from '../../common/vscodeApis/windowApis'; +import { CreateEnv } from '../../common/utils/localize'; import { - CreateEnvironmentExitedEventArgs, - CreateEnvironmentOptions, CreateEnvironmentProvider, + CreateEnvironmentOptions, CreateEnvironmentResult, -} from './types'; -import { showInformationMessage } from '../../common/vscodeApis/windowApis'; -import { CreateEnv } from '../../common/utils/localize'; + ProposedCreateEnvironmentAPI, + EnvironmentDidCreateEvent, +} from './proposed.createEnvApis'; class CreateEnvironmentProviders { private _createEnvProviders: CreateEnvironmentProvider[] = []; @@ -26,6 +27,9 @@ class CreateEnvironmentProviders { } public add(provider: CreateEnvironmentProvider) { + if (this._createEnvProviders.filter((p) => p.id === provider.id).length > 0) { + throw new Error(`Create Environment provider with id ${provider.id} already registered`); + } this._createEnvProviders.push(provider); } @@ -63,15 +67,36 @@ export function registerCreateEnvironmentFeatures( return handleCreateEnvironmentCommand(providers, options); }, ), - ); - disposables.push(registerCreateEnvironmentProvider(new VenvCreationProvider(interpreterQuickPick))); - disposables.push(registerCreateEnvironmentProvider(condaCreationProvider())); - disposables.push( - onCreateEnvironmentExited(async (e: CreateEnvironmentExitedEventArgs) => { - if (e.result?.path && e.options?.selectEnvironment) { - await interpreterPathService.update(e.result.uri, ConfigurationTarget.WorkspaceFolder, e.result.path); - showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.result.path)}`); + registerCreateEnvironmentProvider(new VenvCreationProvider(interpreterQuickPick)), + registerCreateEnvironmentProvider(condaCreationProvider()), + onCreateEnvironmentExited(async (e: EnvironmentDidCreateEvent) => { + if (e.path && e.options?.selectEnvironment) { + await interpreterPathService.update( + e.workspaceFolder?.uri, + ConfigurationTarget.WorkspaceFolder, + e.path, + ); + showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.path)}`); } }), ); } + +export function buildEnvironmentCreationApi(): ProposedCreateEnvironmentAPI { + return { + onWillCreateEnvironment: onCreateEnvironmentStarted, + onDidCreateEnvironment: onCreateEnvironmentExited, + createEnvironment: async ( + options?: CreateEnvironmentOptions | undefined, + ): Promise => { + const providers = _createEnvironmentProviders.getAll(); + try { + return await handleCreateEnvironmentCommand(providers, options); + } catch (err) { + return { path: undefined, workspaceFolder: undefined, action: undefined, error: err as Error }; + } + }, + registerCreateEnvironmentProvider: (provider: CreateEnvironmentProvider) => + registerCreateEnvironmentProvider(provider), + }; +} diff --git a/src/client/pythonEnvironments/creation/createEnvironment.ts b/src/client/pythonEnvironments/creation/createEnvironment.ts index bdeaf89ba82d..4593ff1abf92 100644 --- a/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -11,15 +11,15 @@ import { } from '../../common/vscodeApis/windowApis'; import { traceError, traceVerbose } from '../../logging'; import { - CreateEnvironmentExitedEventArgs, CreateEnvironmentOptions, - CreateEnvironmentProvider, CreateEnvironmentResult, - CreateEnvironmentStartedEventArgs, -} from './types'; + CreateEnvironmentProvider, + EnvironmentWillCreateEvent, + EnvironmentDidCreateEvent, +} from './proposed.createEnvApis'; -const onCreateEnvironmentStartedEvent = new EventEmitter(); -const onCreateEnvironmentExitedEvent = new EventEmitter(); +const onCreateEnvironmentStartedEvent = new EventEmitter(); +const onCreateEnvironmentExitedEvent = new EventEmitter(); let startedEventCount = 0; @@ -32,14 +32,20 @@ function fireStartedEvent(options?: CreateEnvironmentOptions): void { startedEventCount += 1; } -function fireExitedEvent(result?: CreateEnvironmentResult, options?: CreateEnvironmentOptions, error?: unknown): void { - onCreateEnvironmentExitedEvent.fire({ result, options, error }); +function fireExitedEvent(result?: CreateEnvironmentResult, options?: CreateEnvironmentOptions, error?: Error): void { + onCreateEnvironmentExitedEvent.fire({ + options, + workspaceFolder: result?.workspaceFolder, + path: result?.path, + action: result?.action, + error: error || result?.error, + }); startedEventCount -= 1; } export function getCreationEvents(): { - onCreateEnvironmentStarted: Event; - onCreateEnvironmentExited: Event; + onCreateEnvironmentStarted: Event; + onCreateEnvironmentExited: Event; isCreatingEnvironment: () => boolean; } { return { @@ -54,7 +60,7 @@ async function createEnvironment( options: CreateEnvironmentOptions, ): Promise { let result: CreateEnvironmentResult | undefined; - let err: unknown | undefined; + let err: Error | undefined; try { fireStartedEvent(options); result = await provider.createEnvironment(options); @@ -65,7 +71,7 @@ async function createEnvironment( return undefined; } } - err = ex; + err = ex as Error; throw err; } finally { fireExitedEvent(result, options, err); @@ -185,11 +191,7 @@ export async function handleCreateEnvironmentCommand( const action = await MultiStepNode.run(envTypeStep); if (options?.showBackButton) { if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) { - result = { - path: result?.path, - uri: result?.uri, - action: action === MultiStepAction.Back ? 'Back' : 'Cancel', - }; + result = { action, workspaceFolder: undefined, path: undefined, error: undefined }; } } diff --git a/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts b/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts new file mode 100644 index 000000000000..52209a5a31d0 --- /dev/null +++ b/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { Event, Disposable, WorkspaceFolder } from 'vscode'; +import { EnvironmentTools } from '../../apiTypes'; + +export type CreateEnvironmentUserActions = 'Back' | 'Cancel'; +export type EnvironmentProviderId = string; + +/** + * Options used when creating a Python environment. + */ +export interface CreateEnvironmentOptions { + /** + * Default `true`. If `true`, the environment creation handler is expected to install packages. + */ + installPackages?: boolean; + + /** + * Default `true`. If `true`, the environment creation provider is expected to add the environment to ignore list + * for the source control. + */ + ignoreSourceControl?: boolean; + + /** + * Default `false`. If `true` the creation provider should show back button when showing QuickPick or QuickInput. + */ + showBackButton?: boolean; + + /** + * Default `true`. If `true`, the environment after creation will be selected. + */ + selectEnvironment?: boolean; +} + +/** + * Params passed on `onWillCreateEnvironment` event handler. + */ +export interface EnvironmentWillCreateEvent { + /** + * Options used to create a Python environment. + */ + options: CreateEnvironmentOptions | undefined; +} + +/** + * Params passed on `onDidCreateEnvironment` event handler. + */ +export interface EnvironmentDidCreateEvent extends CreateEnvironmentResult { + /** + * Options used to create the Python environment. + */ + options: CreateEnvironmentOptions | undefined; +} + +export interface CreateEnvironmentResult { + /** + * Workspace folder associated with the environment. + */ + workspaceFolder: WorkspaceFolder | undefined; + + /** + * Path to the executable python in the environment + */ + path: string | undefined; + + /** + * User action that resulted in exit from the create environment flow. + */ + action: CreateEnvironmentUserActions | undefined; + + /** + * Error if any occurred during environment creation. + */ + error: Error | undefined; +} + +/** + * Extensions that want to contribute their own environment creation can do that by registering an object + * that implements this interface. + */ +export interface CreateEnvironmentProvider { + /** + * This API is called when user selects this provider from a QuickPick to select the type of environment + * user wants. This API is expected to show a QuickPick or QuickInput to get the user input and return + * the path to the Python executable in the environment. + * + * @param {CreateEnvironmentOptions} [options] Options used to create a Python environment. + * + * @returns a promise that resolves to the path to the + * Python executable in the environment. Or any action taken by the user, such as back or cancel. + */ + createEnvironment(options?: CreateEnvironmentOptions): Promise; + + /** + * Unique ID for the creation provider, typically : + */ + id: EnvironmentProviderId; + + /** + * Display name for the creation provider. + */ + name: string; + + /** + * Description displayed to the user in the QuickPick to select environment provider. + */ + description: string; + + /** + * Tools used to manage this environment. e.g., ['conda']. In the most to least priority order + * for resolving and working with the environment. + */ + tools: EnvironmentTools[]; +} + +export interface ProposedCreateEnvironmentAPI { + /** + * This API can be used to detect when the environment creation starts for any registered + * provider (including internal providers). This will also receive any options passed in + * or defaults used to create environment. + */ + onWillCreateEnvironment: Event; + + /** + * This API can be used to detect when the environment provider exits for any registered + * provider (including internal providers). This will also receive created environment path, + * any errors, or user actions taken from the provider. + */ + onDidCreateEnvironment: Event; + + /** + * This API will show a QuickPick to select an environment provider from available list of + * providers. Based on the selection the `createEnvironment` will be called on the provider. + */ + createEnvironment(options?: CreateEnvironmentOptions): Promise; + + /** + * This API should be called to register an environment creation provider. It returns + * a (@link Disposable} which can be used to remove the registration. + */ + registerCreateEnvironmentProvider(provider: CreateEnvironmentProvider): Disposable; +} diff --git a/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts index 5bf032f9f65f..39cd40afd41a 100644 --- a/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -5,12 +5,7 @@ import { CancellationToken, ProgressLocation, WorkspaceFolder } from 'vscode'; import * as path from 'path'; import { Commands, PVSC_EXTENSION_ID } from '../../../common/constants'; import { traceError, traceLog } from '../../../logging'; -import { - CreateEnvironmentOptions, - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../types'; +import { CreateEnvironmentProgress } from '../types'; import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { execObservable } from '../../../common/process/rawProcessApis'; import { createDeferred } from '../../../common/utils/async'; @@ -28,6 +23,11 @@ import { CONDA_ENV_EXISTING_MARKER, } from './condaProgressAndTelemetry'; import { splitLines } from '../../../common/stringUtils'; +import { + CreateEnvironmentOptions, + CreateEnvironmentResult, + CreateEnvironmentProvider, +} from '../proposed.createEnvApis'; function generateCommandArgs(version?: string, options?: CreateEnvironmentOptions): string[] { let addGitIgnore = true; @@ -247,7 +247,7 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise {} - -export interface CreateEnvironmentOptions { - /** - * Default `true`. If `true`, the environment creation handler is expected to install packages. - */ - installPackages?: boolean; - - /** - * Default `true`. If `true`, the environment creation provider is expected to add the environment to ignore list - * for the source control. - */ - ignoreSourceControl?: boolean; - - /** - * Default `false`. If `true` the creation provider should show back button when showing QuickPick or QuickInput. - */ - showBackButton?: boolean; - - /** - * Default `true`. If `true`, the environment will be selected as the environment to be used for the workspace. - */ - selectEnvironment?: boolean; -} - -export interface CreateEnvironmentResult { - path: string | undefined; - uri: Uri | undefined; - action?: 'Back' | 'Cancel'; -} - -export interface CreateEnvironmentStartedEventArgs { - options: CreateEnvironmentOptions | undefined; -} - -export interface CreateEnvironmentExitedEventArgs { - result: CreateEnvironmentResult | undefined; - error?: unknown; - options: CreateEnvironmentOptions | undefined; -} - -export interface CreateEnvironmentProvider { - createEnvironment(options?: CreateEnvironmentOptions): Promise; - name: string; - description: string; - id: string; -} diff --git a/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts index 1286ac44d58d..786bd26a881c 100644 --- a/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts +++ b/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts @@ -12,8 +12,8 @@ import * as commandApis from '../../../client/common/vscodeApis/commandApis'; import { IInterpreterQuickPick } from '../../../client/interpreter/configuration/types'; import { registerCreateEnvironmentFeatures } from '../../../client/pythonEnvironments/creation/createEnvApi'; import * as windowApis from '../../../client/common/vscodeApis/windowApis'; -import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -57,6 +57,11 @@ suite('Create Environment APIs', () => { [true, false].forEach((selectEnvironment) => { test(`Set environment selectEnvironment == ${selectEnvironment}`, async () => { + const workspace1 = { + uri: Uri.file('/path/to/env'), + name: 'workspace1', + index: 0, + }; const provider = typemoq.Mock.ofType(); provider.setup((p) => p.name).returns(() => 'test'); provider.setup((p) => p.id).returns(() => 'test-id'); @@ -66,7 +71,9 @@ suite('Create Environment APIs', () => { .returns(() => Promise.resolve({ path: '/path/to/env', - uri: Uri.file('/path/to/env'), + workspaceFolder: workspace1, + action: undefined, + error: undefined, }), ); provider.setup((p) => (p as any).then).returns(() => undefined); diff --git a/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts index 507a2aee88cd..f16f81233369 100644 --- a/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts +++ b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts @@ -8,9 +8,9 @@ import * as typemoq from 'typemoq'; import { assert, use as chaiUse } from 'chai'; import * as windowApis from '../../../client/common/vscodeApis/windowApis'; import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; -import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; import { IDisposableRegistry } from '../../../client/common/types'; import { onCreateEnvironmentStarted } from '../../../client/pythonEnvironments/creation/createEnvApi'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -233,7 +233,12 @@ suite('Create Environments Tests', () => { showBackButton: true, }); - assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Back' }); + assert.deepStrictEqual(result, { + action: 'Back', + workspaceFolder: undefined, + path: undefined, + error: undefined, + }); assert.isTrue(showQuickPickStub.notCalled); assert.isTrue(showQuickPickWithBackStub.calledOnce); }); @@ -259,7 +264,12 @@ suite('Create Environments Tests', () => { showBackButton: true, }); - assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Cancel' }); + assert.deepStrictEqual(result, { + action: 'Cancel', + workspaceFolder: undefined, + path: undefined, + error: undefined, + }); assert.isTrue(showQuickPickStub.notCalled); assert.isTrue(showQuickPickWithBackStub.calledOnce); }); diff --git a/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts index db5eb351211e..cb4df95c8c1f 100644 --- a/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts @@ -7,11 +7,7 @@ import { assert, use as chaiUse } from 'chai'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { CancellationToken, ProgressOptions, Uri } from 'vscode'; -import { - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../../../../client/pythonEnvironments/creation/types'; +import { CreateEnvironmentProgress } from '../../../../client/pythonEnvironments/creation/types'; import { condaCreationProvider } from '../../../../client/pythonEnvironments/creation/provider/condaCreationProvider'; import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; @@ -23,6 +19,10 @@ import { createDeferred } from '../../../../client/common/utils/async'; import * as commonUtils from '../../../../client/pythonEnvironments/creation/common/commonUtils'; import { CONDA_ENV_CREATED_MARKER } from '../../../../client/pythonEnvironments/creation/provider/condaProgressAndTelemetry'; import { CreateEnv } from '../../../../client/common/utils/localize'; +import { + CreateEnvironmentProvider, + CreateEnvironmentResult, +} from '../../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -131,7 +131,12 @@ suite('Conda Creation provider tests', () => { _next!({ out: `${CONDA_ENV_CREATED_MARKER}new_environment`, source: 'stdout' }); _complete!(); - assert.deepStrictEqual(await promise, { path: 'new_environment', uri: workspace1.uri }); + assert.deepStrictEqual(await promise, { + path: 'new_environment', + workspaceFolder: workspace1, + action: undefined, + error: undefined, + }); assert.isTrue(showErrorMessageWithLogsStub.notCalled); }); diff --git a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts index b56fb158d6ae..1c22264f2ada 100644 --- a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts @@ -6,11 +6,7 @@ import * as typemoq from 'typemoq'; import { assert, use as chaiUse } from 'chai'; import * as sinon from 'sinon'; import { CancellationToken, ProgressOptions, Uri } from 'vscode'; -import { - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../../../../client/pythonEnvironments/creation/types'; +import { CreateEnvironmentProgress } from '../../../../client/pythonEnvironments/creation/types'; import { VenvCreationProvider } from '../../../../client/pythonEnvironments/creation/provider/venvCreationProvider'; import { IInterpreterQuickPick } from '../../../../client/interpreter/configuration/types'; import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; @@ -23,6 +19,10 @@ import { Output } from '../../../../client/common/process/types'; import { VENV_CREATED_MARKER } from '../../../../client/pythonEnvironments/creation/provider/venvProgressAndTelemetry'; import { CreateEnv } from '../../../../client/common/utils/localize'; import * as venvUtils from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; +import { + CreateEnvironmentProvider, + CreateEnvironmentResult, +} from '../../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -155,7 +155,12 @@ suite('venv Creation provider tests', () => { _complete!(); const actual = await promise; - assert.deepStrictEqual(actual, { path: 'new_environment', uri: workspace1.uri }); + assert.deepStrictEqual(actual, { + path: 'new_environment', + workspaceFolder: workspace1, + action: undefined, + error: undefined, + }); interpreterQuickPick.verifyAll(); progressMock.verifyAll(); assert.isTrue(showErrorMessageWithLogsStub.notCalled);