diff --git a/pythonFiles/create_venv.py b/pythonFiles/create_venv.py index 24d4baa357c9..a97199c4c6c6 100644 --- a/pythonFiles/create_venv.py +++ b/pythonFiles/create_venv.py @@ -154,14 +154,14 @@ def main(argv: Optional[Sequence[str]] = None) -> None: if pip_installed: upgrade_pip(venv_path) - if args.requirements: - print(f"VENV_INSTALLING_REQUIREMENTS: {args.requirements}") - install_requirements(venv_path, args.requirements) - if args.toml: print(f"VENV_INSTALLING_PYPROJECT: {args.toml}") install_toml(venv_path, args.extras) + if args.requirements: + print(f"VENV_INSTALLING_REQUIREMENTS: {args.requirements}") + install_requirements(venv_path, args.requirements) + if __name__ == "__main__": main(sys.argv[1:]) diff --git a/src/client/common/utils/async.ts b/src/client/common/utils/async.ts index c577842b63dc..29bf4a8d6fca 100644 --- a/src/client/common/utils/async.ts +++ b/src/client/common/utils/async.ts @@ -27,7 +27,7 @@ export interface Deferred { readonly rejected: boolean; readonly completed: boolean; resolve(value?: T | PromiseLike): void; - reject(reason?: string | Error | Record): void; + reject(reason?: string | Error | Record | unknown): void; } class DeferredImpl implements Deferred { diff --git a/src/client/common/utils/multiStepInput.ts b/src/client/common/utils/multiStepInput.ts index 94f44dd9a9e8..22559e0dbdd1 100644 --- a/src/client/common/utils/multiStepInput.ts +++ b/src/client/common/utils/multiStepInput.ts @@ -8,6 +8,7 @@ import { inject, injectable } from 'inversify'; import { Disposable, QuickInput, QuickInputButton, QuickInputButtons, QuickPick, QuickPickItem, Event } from 'vscode'; import { IApplicationShell } from '../application/types'; +import { createDeferred } from './async'; // Borrowed from https://github.com/Microsoft/vscode-extension-samples/blob/master/quickinput-sample/src/multiStepInput.ts // Why re-invent the wheel :) @@ -29,7 +30,7 @@ export type InputStep = (input: MultiStepInput, state: T) => P type buttonCallbackType = (quickPick: QuickPick) => void; -type QuickInputButtonSetup = { +export type QuickInputButtonSetup = { /** * Button for an action in a QuickPick. */ @@ -164,35 +165,41 @@ export class MultiStepInput implements IMultiStepInput { // so do it after initialization. This ensures quickpick starts with the active // item in focus when this is true, instead of having scroll position at top. input.keepScrollPosition = keepScrollPosition; - try { - return await new Promise>((resolve, reject) => { - disposables.push( - input.onDidTriggerButton(async (item) => { - if (item === QuickInputButtons.Back) { - reject(InputFlowAction.back); - } - if (customButtonSetups) { - for (const customButtonSetup of customButtonSetups) { - if (JSON.stringify(item) === JSON.stringify(customButtonSetup?.button)) { - await customButtonSetup?.callback(input); - } - } + + const deferred = createDeferred(); + + disposables.push( + input.onDidTriggerButton(async (item) => { + if (item === QuickInputButtons.Back) { + deferred.reject(InputFlowAction.back); + input.hide(); + } + if (customButtonSetups) { + for (const customButtonSetup of customButtonSetups) { + if (JSON.stringify(item) === JSON.stringify(customButtonSetup?.button)) { + await customButtonSetup?.callback(input); } - }), - input.onDidChangeSelection((selectedItems) => resolve(selectedItems[0])), - input.onDidHide(() => { - resolve(undefined); - }), - ); - if (acceptFilterBoxTextAsSelection) { - disposables.push( - input.onDidAccept(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolve(input.value as any); - }), - ); + } } - }); + }), + input.onDidChangeSelection((selectedItems) => deferred.resolve(selectedItems[0])), + input.onDidHide(() => { + if (!deferred.completed) { + deferred.resolve(undefined); + } + }), + ); + if (acceptFilterBoxTextAsSelection) { + disposables.push( + input.onDidAccept(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + deferred.resolve(input.value as any); + }), + ); + } + + try { + return await deferred.promise; } finally { disposables.forEach((d) => d.dispose()); } @@ -277,6 +284,9 @@ export class MultiStepInput implements IMultiStepInput { if (err === InputFlowAction.back) { this.steps.pop(); step = this.steps.pop(); + if (step === undefined) { + throw err; + } } else if (err === InputFlowAction.resume) { step = this.steps.pop(); } else if (err === InputFlowAction.cancel) { diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index be2c362a4b45..c33c14ca9875 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -1,5 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ import { CancellationToken, @@ -7,13 +9,16 @@ import { MessageOptions, Progress, ProgressOptions, + QuickPick, + QuickInputButtons, QuickPickItem, QuickPickOptions, TextEditor, window, + Disposable, } from 'vscode'; +import { createDeferred, Deferred } from '../utils/async'; -/* eslint-disable @typescript-eslint/no-explicit-any */ export function showQuickPick( items: readonly T[] | Thenable, options?: QuickPickOptions, @@ -22,6 +27,10 @@ export function showQuickPick( return window.showQuickPick(items, options, token); } +export function createQuickPick(): QuickPick { + return window.createQuickPick(); +} + export function showErrorMessage(message: string, ...items: T[]): Thenable; export function showErrorMessage( message: string, @@ -67,3 +76,122 @@ export function getActiveTextEditor(): TextEditor | undefined { const { activeTextEditor } = window; return activeTextEditor; } + +export enum MultiStepAction { + Back = 'Back', + Cancel = 'Cancel', + Continue = 'Continue', +} + +export async function showQuickPickWithBack( + items: readonly T[], + options?: QuickPickOptions, + token?: CancellationToken, +): Promise { + const quickPick: QuickPick = window.createQuickPick(); + const disposables: Disposable[] = [quickPick]; + + quickPick.items = items; + quickPick.buttons = [QuickInputButtons.Back]; + quickPick.canSelectMany = options?.canPickMany ?? false; + quickPick.ignoreFocusOut = options?.ignoreFocusOut ?? false; + quickPick.matchOnDescription = options?.matchOnDescription ?? false; + quickPick.matchOnDetail = options?.matchOnDetail ?? false; + quickPick.placeholder = options?.placeHolder; + quickPick.title = options?.title; + + const deferred = createDeferred(); + + disposables.push( + quickPick, + quickPick.onDidTriggerButton((item) => { + if (item === QuickInputButtons.Back) { + deferred.reject(MultiStepAction.Back); + quickPick.hide(); + } + }), + quickPick.onDidAccept(() => { + if (!deferred.completed) { + deferred.resolve(quickPick.selectedItems.map((item) => item)); + quickPick.hide(); + } + }), + quickPick.onDidHide(() => { + if (!deferred.completed) { + deferred.resolve(undefined); + } + }), + ); + if (token) { + disposables.push( + token.onCancellationRequested(() => { + quickPick.hide(); + }), + ); + } + quickPick.show(); + + try { + return await deferred.promise; + } finally { + disposables.forEach((d) => d.dispose()); + } +} + +export class MultiStepNode { + constructor( + public previous: MultiStepNode | undefined, + public readonly current: (context?: MultiStepAction) => Promise, + public next: MultiStepNode | undefined, + ) {} + + public static async run(step: MultiStepNode, context?: MultiStepAction): Promise { + let nextStep: MultiStepNode | undefined = step; + let flowAction = await nextStep.current(context); + while (nextStep !== undefined) { + if (flowAction === MultiStepAction.Cancel) { + return flowAction; + } + if (flowAction === MultiStepAction.Back) { + nextStep = nextStep?.previous; + } + if (flowAction === MultiStepAction.Continue) { + nextStep = nextStep?.next; + } + + if (nextStep) { + flowAction = await nextStep?.current(flowAction); + } + } + + return flowAction; + } +} + +export function createStepBackEndNode(deferred?: Deferred): MultiStepNode { + return new MultiStepNode( + undefined, + async () => { + if (deferred) { + // This is to ensure we don't leave behind any pending promises. + deferred.reject(MultiStepAction.Back); + } + return Promise.resolve(MultiStepAction.Back); + }, + undefined, + ); +} + +export function createStepForwardEndNode(deferred?: Deferred, result?: T): MultiStepNode { + return new MultiStepNode( + undefined, + async () => { + if (deferred) { + // This is to ensure we don't leave behind any pending promises. + deferred.resolve(result); + } + return Promise.resolve(MultiStepAction.Back); + }, + undefined, + ); +} diff --git a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 717e943bae84..d0989d7609df 100644 --- a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -6,7 +6,15 @@ import { inject, injectable } from 'inversify'; import { cloneDeep } from 'lodash'; import * as path from 'path'; -import { l10n, QuickPick, QuickPickItem, QuickPickItemKind, ThemeIcon } from 'vscode'; +import { + l10n, + QuickInputButton, + QuickInputButtons, + QuickPick, + QuickPickItem, + QuickPickItemKind, + ThemeIcon, +} from 'vscode'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../common/application/types'; import { Commands, Octicons, ThemeIcons } from '../../../../common/constants'; import { isParentPath } from '../../../../common/platform/fs-paths'; @@ -20,6 +28,7 @@ import { InputFlowAction, InputStep, IQuickPickParameters, + QuickInputButtonSetup, } from '../../../../common/utils/multiStepInput'; import { SystemVariables } from '../../../../common/variables/systemVariables'; import { TriggerRefreshOptions } from '../../../../pythonEnvironments/base/locator'; @@ -145,6 +154,23 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem : params?.placeholder ?? l10n.t('Selected Interpreter: {0}', currentInterpreterPathDisplay); const title = params?.title === null ? undefined : params?.title ?? InterpreterQuickPickList.browsePath.openButtonLabel; + const buttons: QuickInputButtonSetup[] = [ + { + button: this.refreshButton, + callback: (quickpickInput) => { + this.refreshCallback(quickpickInput, { isButton: true, showBackButton: params?.showBackButton }); + }, + }, + ]; + if (params?.showBackButton) { + buttons.push({ + button: QuickInputButtons.Back, + callback: () => { + // Do nothing. This is handled as a promise rejection in the quickpick. + }, + }); + } + const selection = await input.showQuickPick>({ placeholder, items: suggestions, @@ -154,23 +180,19 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem matchOnDetail: true, matchOnDescription: true, title, - customButtonSetups: [ - { - button: this.refreshButton, - callback: (quickpickInput) => { - this.refreshCallback(quickpickInput, { isButton: true }); - }, - }, - ], + customButtonSetups: buttons, initialize: (quickPick) => { // Note discovery is no longer guranteed to be auto-triggered on extension load, so trigger it when // user interacts with the interpreter picker but only once per session. Users can rely on the // refresh button if they want to trigger it more than once. However if no envs were found previously, // always trigger a refresh. if (this.interpreterService.getInterpreters().length === 0) { - this.refreshCallback(quickPick); + this.refreshCallback(quickPick, { showBackButton: params?.showBackButton }); } else { - this.refreshCallback(quickPick, { ifNotTriggerredAlready: true }); + this.refreshCallback(quickPick, { + ifNotTriggerredAlready: true, + showBackButton: params?.showBackButton, + }); } }, onChangeItem: { @@ -436,19 +458,16 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem } } - private refreshCallback(input: QuickPick, options?: TriggerRefreshOptions & { isButton?: boolean }) { - if (options?.isButton) { - input.buttons = [ - { - iconPath: new ThemeIcon(ThemeIcons.SpinningLoader), - tooltip: InterpreterQuickPickList.refreshingInterpreterList, - }, - ]; - } + private refreshCallback( + input: QuickPick, + options?: TriggerRefreshOptions & { isButton?: boolean; showBackButton?: boolean }, + ) { + input.buttons = this.getButtons(options); + this.interpreterService .triggerRefresh(undefined, options) .finally(() => { - input.buttons = [this.refreshButton]; + input.buttons = this.getButtons({ isButton: false, showBackButton: options?.showBackButton }); }) .ignoreErrors(); if (this.interpreterService.refreshPromise) { @@ -459,6 +478,22 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem } } + private getButtons(options?: { isButton?: boolean; showBackButton?: boolean }): QuickInputButton[] { + const buttons: QuickInputButton[] = []; + if (options?.showBackButton) { + buttons.push(QuickInputButtons.Back); + } + if (options?.isButton) { + buttons.push({ + iconPath: new ThemeIcon(ThemeIcons.SpinningLoader), + tooltip: InterpreterQuickPickList.refreshingInterpreterList, + }); + } else { + buttons.push(this.refreshButton); + } + return buttons; + } + @captureTelemetry(EventName.SELECT_INTERPRETER_ENTER_BUTTON) public async _enterOrBrowseInterpreterPath( input: IMultiStepInput, diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index bd0553d6762e..90facb7fe640 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -79,6 +79,11 @@ export interface InterpreterQuickPickParams { * Specify `true` to skip showing recommended python interpreter. */ skipRecommended?: boolean; + + /** + * Specify `true` to show back button. + */ + showBackButton?: boolean; } export const IInterpreterQuickPick = Symbol('IInterpreterQuickPick'); diff --git a/src/client/pythonEnvironments/creation/common/workspaceSelection.ts b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts index 625aaa5c265e..d145e38134d1 100644 --- a/src/client/pythonEnvironments/creation/common/workspaceSelection.ts +++ b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts @@ -4,7 +4,7 @@ import * as fsapi from 'fs-extra'; import * as path from 'path'; import { CancellationToken, QuickPickItem, WorkspaceFolder } from 'vscode'; -import { showErrorMessage, showQuickPick } from '../../../common/vscodeApis/windowApis'; +import { MultiStepAction, showErrorMessage, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis'; import { getWorkspaceFolders } from '../../../common/vscodeApis/workspaceApis'; import { Common, CreateEnv } from '../../../common/utils/localize'; import { executeCommand } from '../../../common/vscodeApis/commandApis'; @@ -36,10 +36,15 @@ export interface PickWorkspaceFolderOptions { export async function pickWorkspaceFolder( options?: PickWorkspaceFolderOptions, + context?: MultiStepAction, ): Promise { const workspaces = getWorkspaceFolders(); if (!workspaces || workspaces.length === 0) { + if (context === MultiStepAction.Back) { + // No workspaces and nothing to show, should just go to previous + throw MultiStepAction.Back; + } const result = await showErrorMessage(CreateEnv.noWorkspace, Common.openFolder); if (result === Common.openFolder) { await executeCommand('vscode.openFolder'); @@ -48,12 +53,17 @@ export async function pickWorkspaceFolder( } if (workspaces.length === 1) { + if (context === MultiStepAction.Back) { + // In this case there is no Quick Pick shown, should just go to previous + throw MultiStepAction.Back; + } + return workspaces[0]; } // This is multi-root scenario. - const selected = await showQuickPick( - getWorkspacesForQuickPick(workspaces), + const selected = await showQuickPickWithBack( + await getWorkspacesForQuickPick(workspaces), { placeHolder: CreateEnv.pickWorkspacePlaceholder, ignoreFocusOut: true, @@ -63,13 +73,11 @@ export async function pickWorkspaceFolder( ); if (selected) { - if (options?.allowMultiSelect) { - const details = ((selected as unknown) as QuickPickItem[]) - .map((s: QuickPickItem) => s.detail) - .filter((s) => s !== undefined); + if (Array.isArray(selected)) { + const details = selected.map((s: QuickPickItem) => s.detail).filter((s) => s !== undefined); return workspaces.filter((w) => details.includes(w.uri.fsPath)); } - return workspaces.filter((w) => w.uri.fsPath === selected.detail)[0]; + return workspaces.filter((w) => w.uri.fsPath === (selected as QuickPickItem).detail)[0]; } return undefined; diff --git a/src/client/pythonEnvironments/creation/createEnvironment.ts b/src/client/pythonEnvironments/creation/createEnvironment.ts index 7489da89f123..2f21d787d336 100644 --- a/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -1,10 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License -import { Event, EventEmitter, QuickPickItem } from 'vscode'; +import { Event, EventEmitter, QuickInputButtons, QuickPickItem } from 'vscode'; import { CreateEnv } from '../../common/utils/localize'; -import { showQuickPick } from '../../common/vscodeApis/windowApis'; -import { traceError } from '../../logging'; +import { + MultiStepAction, + MultiStepNode, + showQuickPick, + showQuickPickWithBack, +} from '../../common/vscodeApis/windowApis'; +import { traceError, traceVerbose } from '../../logging'; import { CreateEnvironmentOptions, CreateEnvironmentProvider, CreateEnvironmentResult } from './types'; const onCreateEnvironmentStartedEvent = new EventEmitter(); @@ -49,6 +54,14 @@ async function createEnvironment( try { fireStartedEvent(); result = await provider.createEnvironment(options); + } catch (ex) { + if (ex === QuickInputButtons.Back) { + traceVerbose('Create Env: User clicked back button during environment creation'); + if (!options.showBackButton) { + return undefined; + } + } + throw ex; } finally { fireExitedEvent(result); } @@ -61,22 +74,37 @@ interface CreateEnvironmentProviderQuickPickItem extends QuickPickItem { async function showCreateEnvironmentQuickPick( providers: readonly CreateEnvironmentProvider[], + options?: CreateEnvironmentOptions, ): Promise { const items: CreateEnvironmentProviderQuickPickItem[] = providers.map((p) => ({ label: p.name, description: p.description, id: p.id, })); - const selected = await showQuickPick(items, { - placeHolder: CreateEnv.providersQuickPickPlaceholder, - matchOnDescription: true, - ignoreFocusOut: true, - }); - - if (selected) { - const selections = providers.filter((p) => p.id === selected.id); - if (selections.length > 0) { - return selections[0]; + + let selectedItem: CreateEnvironmentProviderQuickPickItem | CreateEnvironmentProviderQuickPickItem[] | undefined; + + if (options?.showBackButton) { + selectedItem = await showQuickPickWithBack(items, { + placeHolder: CreateEnv.providersQuickPickPlaceholder, + matchOnDescription: true, + ignoreFocusOut: true, + }); + } else { + selectedItem = await showQuickPick(items, { + placeHolder: CreateEnv.providersQuickPickPlaceholder, + matchOnDescription: true, + ignoreFocusOut: true, + }); + } + + if (selectedItem) { + const selected = Array.isArray(selectedItem) ? selectedItem[0] : selectedItem; + if (selected) { + const selections = providers.filter((p) => p.id === selected.id); + if (selections.length > 0) { + return selections[0]; + } } } return undefined; @@ -86,16 +114,68 @@ export async function handleCreateEnvironmentCommand( providers: readonly CreateEnvironmentProvider[], options?: CreateEnvironmentOptions, ): Promise { - if (providers.length === 1) { - return createEnvironment(providers[0], options); - } - if (providers.length > 1) { - const provider = await showCreateEnvironmentQuickPick(providers); - if (provider) { - return createEnvironment(provider, options); + let selectedProvider: CreateEnvironmentProvider | undefined; + const envTypeStep = new MultiStepNode( + undefined, + async (context?: MultiStepAction) => { + if (providers.length > 0) { + try { + selectedProvider = await showCreateEnvironmentQuickPick(providers, options); + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + if (!selectedProvider) { + return MultiStepAction.Cancel; + } + } else { + traceError('No Environment Creation providers were registered.'); + if (context === MultiStepAction.Back) { + // There are no providers to select, so just step back. + return MultiStepAction.Back; + } + } + return MultiStepAction.Continue; + }, + undefined, + ); + + let result: CreateEnvironmentResult | undefined; + const createStep = new MultiStepNode( + envTypeStep, + async (context?: MultiStepAction) => { + if (context === MultiStepAction.Back) { + // This step is to trigger creation, which can go into other extension. + return MultiStepAction.Back; + } + if (selectedProvider) { + try { + result = await createEnvironment(selectedProvider, options); + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + } + return MultiStepAction.Continue; + }, + undefined, + ); + envTypeStep.next = createStep; + + 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', + }; } - } else { - traceError('No Environment Creation providers were registered.'); } - return undefined; + + return result; } diff --git a/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts index 91954c620c01..28046cbc73ad 100644 --- a/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -19,7 +19,7 @@ import { createCondaScript } from '../../../common/process/internal/scripts'; import { Common, CreateEnv } from '../../../common/utils/localize'; import { getCondaBaseEnv, pickPythonVersion } from './condaUtils'; import { showErrorMessageWithLogs } from '../common/commonUtils'; -import { withProgress } from '../../../common/vscodeApis/windowApis'; +import { MultiStepAction, MultiStepNode, withProgress } from '../../../common/vscodeApis/windowApis'; import { EventName } from '../../../telemetry/constants'; import { sendTelemetryEvent } from '../../../telemetry'; import { @@ -157,16 +157,50 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise { + try { + workspace = (await pickWorkspaceFolder(undefined, context)) as WorkspaceFolder | undefined; + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } - const version = await pickPythonVersion(); - if (!version) { - traceError('Conda environments for use with python extension require Python.'); - return undefined; + if (workspace === undefined) { + traceError('Workspace was not selected or found for creating conda environment.'); + return MultiStepAction.Cancel; + } + return MultiStepAction.Continue; + }, + undefined, + ); + + let version: string | undefined; + const versionStep = new MultiStepNode( + workspaceStep, + async () => { + try { + version = await pickPythonVersion(); + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + + return MultiStepAction.Continue; + }, + undefined, + ); + workspaceStep.next = versionStep; + + const action = await MultiStepNode.run(workspaceStep); + if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) { + throw action; } return withProgress( @@ -191,13 +225,15 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise { const conda = await Conda.getConda(); @@ -36,16 +39,21 @@ export async function getCondaBaseEnv(): Promise { } export async function pickPythonVersion(token?: CancellationToken): Promise { - const items: QuickPickItem[] = ['3.10', '3.9', '3.8', '3.7'].map((v) => ({ - label: `Python`, + const items: QuickPickItem[] = ['3.10', '3.11', '3.9', '3.8', '3.7'].map((v) => ({ + label: v === RECOMMENDED_CONDA_PYTHON ? `${Octicons.Star} Python` : 'Python', description: v, })); - const version = await showQuickPick( + const selection = await showQuickPickWithBack( items, { placeHolder: CreateEnv.Conda.selectPythonQuickPickPlaceholder, }, token, ); - return version?.description; + + if (selection) { + return (selection as QuickPickItem).description; + } + + return undefined; } diff --git a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts index 850142372d2a..48baa70a745d 100644 --- a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -18,14 +18,15 @@ import { import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { IInterpreterQuickPick } from '../../../interpreter/configuration/types'; import { EnvironmentType, PythonEnvironment } from '../../info'; -import { withProgress } from '../../../common/vscodeApis/windowApis'; +import { MultiStepAction, MultiStepNode, withProgress } from '../../../common/vscodeApis/windowApis'; import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; import { VenvProgressAndTelemetry, VENV_CREATED_MARKER, VENV_EXISTING_MARKER } from './venvProgressAndTelemetry'; import { showErrorMessageWithLogs } from '../common/commonUtils'; import { IPackageInstallSelection, pickPackagesToInstall } from './venvUtils'; +import { InputFlowAction } from '../../../common/utils/multiStepInput'; -function generateCommandArgs(installInfo?: IPackageInstallSelection, addGitIgnore?: boolean): string[] { +function generateCommandArgs(installInfo?: IPackageInstallSelection[], addGitIgnore?: boolean): string[] { const command: string[] = [createVenvScript()]; if (addGitIgnore) { @@ -33,12 +34,23 @@ function generateCommandArgs(installInfo?: IPackageInstallSelection, addGitIgnor } if (installInfo) { - if (installInfo?.installType === 'toml') { - command.push('--toml', installInfo.source?.fileToCommandArgumentForPythonExt() || 'pyproject.toml'); - installInfo.installList?.forEach((i) => command.push('--extras', i)); - } else if (installInfo?.installType === 'requirements') { - installInfo.installList?.forEach((i) => command.push('--requirements', i)); + if (installInfo.some((i) => i.installType === 'toml')) { + const source = installInfo.find((i) => i.installType === 'toml')?.source; + command.push('--toml', source?.fileToCommandArgumentForPythonExt() || 'pyproject.toml'); } + const extras = installInfo.filter((i) => i.installType === 'toml').map((i) => i.installItem); + extras.forEach((r) => { + if (r) { + command.push('--extras', r); + } + }); + + const requirements = installInfo.filter((i) => i.installType === 'requirements').map((i) => i.installItem); + requirements.forEach((r) => { + if (r) { + command.push('--requirements', r); + } + }); } return command; @@ -115,23 +127,64 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { constructor(private readonly interpreterQuickPick: IInterpreterQuickPick) {} public async createEnvironment(options?: CreateEnvironmentOptions): Promise { - const workspace = (await pickWorkspaceFolder()) as WorkspaceFolder | undefined; - if (workspace === undefined) { - traceError('Workspace was not selected or found for creating virtual environment.'); - return undefined; - } + let workspace: WorkspaceFolder | undefined; + const workspaceStep = new MultiStepNode( + undefined, + async (context?: MultiStepAction) => { + try { + workspace = (await pickWorkspaceFolder(undefined, context)) as WorkspaceFolder | undefined; + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } - const interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( - workspace.uri, - (i: PythonEnvironment) => - [EnvironmentType.System, EnvironmentType.MicrosoftStore, EnvironmentType.Global].includes(i.envType), - { skipRecommended: true }, + if (workspace === undefined) { + traceError('Workspace was not selected or found for creating virtual environment.'); + return MultiStepAction.Cancel; + } + return MultiStepAction.Continue; + }, + undefined, ); - if (!interpreter) { - traceError('Virtual env creation requires an interpreter.'); - return undefined; - } + let interpreter: string | undefined; + const interpreterStep = new MultiStepNode( + workspaceStep, + async () => { + if (workspace) { + try { + interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( + workspace.uri, + (i: PythonEnvironment) => + [ + EnvironmentType.System, + EnvironmentType.MicrosoftStore, + EnvironmentType.Global, + ].includes(i.envType), + { + skipRecommended: true, + showBackButton: true, + }, + ); + } catch (ex) { + if (ex === InputFlowAction.back) { + return MultiStepAction.Back; + } + interpreter = undefined; + } + } + + if (!interpreter) { + traceError('Virtual env creation requires an interpreter.'); + return MultiStepAction.Cancel; + } + return MultiStepAction.Continue; + }, + undefined, + ); + workspaceStep.next = interpreterStep; let addGitIgnore = true; let installPackages = true; @@ -139,17 +192,38 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { addGitIgnore = options?.ignoreSourceControl !== undefined ? options.ignoreSourceControl : true; installPackages = options?.installPackages !== undefined ? options.installPackages : true; } - let installInfo: IPackageInstallSelection | undefined; - if (installPackages) { - installInfo = await pickPackagesToInstall(workspace); - } - const args = generateCommandArgs(installInfo, addGitIgnore); + let installInfo: IPackageInstallSelection[] | undefined; + const packagesStep = new MultiStepNode( + interpreterStep, + async () => { + if (workspace && installPackages) { + try { + installInfo = await pickPackagesToInstall(workspace); + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + if (!installInfo) { + traceInfo('Virtual env creation exited during dependencies selection.'); + return MultiStepAction.Cancel; + } + } - if (!installInfo) { - traceInfo('Virtual env creation exited during dependencies selection.'); - return undefined; + return MultiStepAction.Continue; + }, + undefined, + ); + interpreterStep.next = packagesStep; + + const action = await MultiStepNode.run(workspaceStep); + if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) { + throw action; } + const args = generateCommandArgs(installInfo, addGitIgnore); + return withProgress( { location: ProgressLocation.Notification, @@ -168,7 +242,7 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { let envPath: string | undefined; try { - if (interpreter) { + if (interpreter && workspace) { envPath = await createVenv(workspace, interpreter, args, progress, token); } } catch (ex) { @@ -181,7 +255,7 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { } } - return { path: envPath, uri: workspace.uri }; + return { path: envPath, uri: workspace?.uri }; }, ); } diff --git a/src/client/pythonEnvironments/creation/provider/venvUtils.ts b/src/client/pythonEnvironments/creation/provider/venvUtils.ts index 234b2d1a8cb9..aa2ec7efca9e 100644 --- a/src/client/pythonEnvironments/creation/provider/venvUtils.ts +++ b/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -7,7 +7,7 @@ import { flatten, isArray } from 'lodash'; import * as path from 'path'; import { CancellationToken, QuickPickItem, RelativePattern, WorkspaceFolder } from 'vscode'; import { CreateEnv } from '../../../common/utils/localize'; -import { showQuickPick } from '../../../common/vscodeApis/windowApis'; +import { MultiStepAction, MultiStepNode, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis'; import { findFiles } from '../../../common/vscodeApis/workspaceApis'; import { traceError, traceInfo, traceVerbose } from '../../../logging'; @@ -52,7 +52,7 @@ function getTomlOptionalDeps(toml: tomljs.JsonMap): string[] { async function pickTomlExtras(extras: string[], token?: CancellationToken): Promise { const items: QuickPickItem[] = extras.map((e) => ({ label: e })); - const selection = await showQuickPick( + const selection = await showQuickPickWithBack( items, { placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, @@ -84,7 +84,7 @@ async function pickRequirementsFiles(files: string[], token?: CancellationToken) }) .map((e) => ({ label: e })); - const selection = await showQuickPick( + const selection = await showQuickPickWithBack( items, { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, @@ -103,57 +103,117 @@ async function pickRequirementsFiles(files: string[], token?: CancellationToken) export interface IPackageInstallSelection { installType: 'toml' | 'requirements' | 'none'; - installList: string[]; + installItem?: string; source?: string; } export async function pickPackagesToInstall( workspaceFolder: WorkspaceFolder, token?: CancellationToken, -): Promise { +): Promise { const tomlPath = path.join(workspaceFolder.uri.fsPath, 'pyproject.toml'); - traceVerbose(`Looking for toml pyproject.toml with optional dependencies at: ${tomlPath}`); - - let extras: string[] = []; - let tomlExists = false; - let hasBuildSystem = false; - if (await fs.pathExists(tomlPath)) { - tomlExists = true; - const toml = tomlParse(await fs.readFile(tomlPath, 'utf-8')); - extras = getTomlOptionalDeps(toml); - hasBuildSystem = tomlHasBuildSystem(toml); - } + const packages: IPackageInstallSelection[] = []; - if (tomlExists && hasBuildSystem) { - if (extras.length === 0) { - return { installType: 'toml', installList: [], source: tomlPath }; - } - traceVerbose('Found toml with optional dependencies.'); - const installList = await pickTomlExtras(extras, token); - if (installList) { - return { installType: 'toml', installList, source: tomlPath }; - } - return undefined; - } - if (tomlExists) { - traceInfo('Create env: Found toml without optional dependencies or build system.'); - } + const tomlStep = new MultiStepNode( + undefined, + async (context?: MultiStepAction) => { + traceVerbose(`Looking for toml pyproject.toml with optional dependencies at: ${tomlPath}`); + + let extras: string[] = []; + let hasBuildSystem = false; + + if (await fs.pathExists(tomlPath)) { + const toml = tomlParse(await fs.readFile(tomlPath, 'utf-8')); + extras = getTomlOptionalDeps(toml); + hasBuildSystem = tomlHasBuildSystem(toml); + + if (!hasBuildSystem) { + traceInfo('Create env: Found toml without build system. So we will not use editable install.'); + } + if (extras.length === 0) { + traceInfo('Create env: Found toml without optional dependencies.'); + } + } else if (context === MultiStepAction.Back) { + // This step is not really used so just go back + return MultiStepAction.Back; + } - traceVerbose('Looking for pip requirements.'); - const requirementFiles = (await getPipRequirementsFiles(workspaceFolder, token))?.map((p) => - path.relative(workspaceFolder.uri.fsPath, p), + if (hasBuildSystem) { + if (extras.length > 0) { + traceVerbose('Create Env: Found toml with optional dependencies.'); + + try { + const installList = await pickTomlExtras(extras, token); + if (installList) { + if (installList.length > 0) { + installList.forEach((i) => { + packages.push({ installType: 'toml', installItem: i, source: tomlPath }); + }); + } + packages.push({ installType: 'toml', source: tomlPath }); + } + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + } else if (context === MultiStepAction.Back) { + // This step is not really used so just go back + return MultiStepAction.Back; + } else { + // There are no extras to install and the context is to go to next step + packages.push({ installType: 'toml', source: tomlPath }); + } + } else if (context === MultiStepAction.Back) { + // This step is not really used because there is no build system in toml, so just go back + return MultiStepAction.Back; + } + + return MultiStepAction.Continue; + }, + undefined, ); - if (requirementFiles && requirementFiles.length > 0) { - traceVerbose('Found pip requirements.'); - const installList = (await pickRequirementsFiles(requirementFiles, token))?.map((p) => - path.join(workspaceFolder.uri.fsPath, p), - ); - if (installList) { - return { installType: 'requirements', installList }; - } - return undefined; + const requirementsStep = new MultiStepNode( + tomlStep, + async (context?: MultiStepAction) => { + traceVerbose('Looking for pip requirements.'); + const requirementFiles = (await getPipRequirementsFiles(workspaceFolder, token))?.map((p) => + path.relative(workspaceFolder.uri.fsPath, p), + ); + + if (requirementFiles && requirementFiles.length > 0) { + traceVerbose('Found pip requirements.'); + try { + const result = await pickRequirementsFiles(requirementFiles, token); + const installList = result?.map((p) => path.join(workspaceFolder.uri.fsPath, p)); + if (installList) { + installList.forEach((i) => { + packages.push({ installType: 'requirements', installItem: i }); + }); + } + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + } else if (context === MultiStepAction.Back) { + // This step is not really used, because there were no requirement files, so just go back + return MultiStepAction.Back; + } + + return MultiStepAction.Continue; + }, + undefined, + ); + tomlStep.next = requirementsStep; + + const action = await MultiStepNode.run(tomlStep); + if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) { + throw action; } - return { installType: 'none', installList: [] }; + return packages; } diff --git a/src/client/pythonEnvironments/creation/types.ts b/src/client/pythonEnvironments/creation/types.ts index 6c844b8cfd02..c18249a2bd72 100644 --- a/src/client/pythonEnvironments/creation/types.ts +++ b/src/client/pythonEnvironments/creation/types.ts @@ -8,11 +8,13 @@ export interface CreateEnvironmentProgress extends Progress<{ message?: string; export interface CreateEnvironmentOptions { installPackages?: boolean; ignoreSourceControl?: boolean; + showBackButton?: boolean; } export interface CreateEnvironmentResult { path: string | undefined; uri: Uri | undefined; + action?: 'Back' | 'Cancel'; } export interface CreateEnvironmentProvider { diff --git a/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts b/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts index 03ec08ecd83b..57047db1d2bc 100644 --- a/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts +++ b/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import * as path from 'path'; -import { assert } from 'chai'; +import { assert, expect, use as chaiUse } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; // import * as typemoq from 'typemoq'; import { Uri, WorkspaceFolder } from 'vscode'; @@ -11,13 +12,15 @@ import { pickWorkspaceFolder } from '../../../../client/pythonEnvironments/creat import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; +chaiUse(chaiAsPromised); + suite('Create environment workspace selection tests', () => { - let showQuickPickStub: sinon.SinonStub; + let showQuickPickWithBackStub: sinon.SinonStub; let getWorkspaceFoldersStub: sinon.SinonStub; let showErrorMessageStub: sinon.SinonStub; setup(() => { - showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); }); @@ -38,7 +41,7 @@ suite('Create environment workspace selection tests', () => { assert.isTrue(showErrorMessageStub.calledOnce); }); - test('User did not select workspace', async () => { + test('User did not select workspace or user hit escape', async () => { const workspaces: WorkspaceFolder[] = [ { uri: Uri.file('some_folder'), @@ -53,10 +56,29 @@ suite('Create environment workspace selection tests', () => { ]; getWorkspaceFoldersStub.returns(workspaces); - showQuickPickStub.returns(undefined); + showQuickPickWithBackStub.returns(undefined); assert.isUndefined(await pickWorkspaceFolder()); }); + test('User clicked on the back button', async () => { + const workspaces: WorkspaceFolder[] = [ + { + uri: Uri.file('some_folder'), + name: 'some_folder', + index: 0, + }, + { + uri: Uri.file('some_folder2'), + name: 'some_folder2', + index: 1, + }, + ]; + + getWorkspaceFoldersStub.returns(workspaces); + showQuickPickWithBackStub.throws(windowApis.MultiStepAction.Back); + expect(pickWorkspaceFolder()).to.eventually.be.rejectedWith(windowApis.MultiStepAction.Back); + }); + test('single workspace scenario', async () => { const workspaces: WorkspaceFolder[] = [ { @@ -67,7 +89,7 @@ suite('Create environment workspace selection tests', () => { ]; getWorkspaceFoldersStub.returns(workspaces); - showQuickPickStub.returns({ + showQuickPickWithBackStub.returns({ label: workspaces[0].name, detail: workspaces[0].uri.fsPath, description: undefined, @@ -75,7 +97,7 @@ suite('Create environment workspace selection tests', () => { const workspace = await pickWorkspaceFolder(); assert.deepEqual(workspace, workspaces[0]); - assert(showQuickPickStub.notCalled); + assert(showQuickPickWithBackStub.notCalled); }); test('Multi-workspace scenario with single workspace selected', async () => { @@ -108,7 +130,7 @@ suite('Create environment workspace selection tests', () => { ]; getWorkspaceFoldersStub.returns(workspaces); - showQuickPickStub.returns({ + showQuickPickWithBackStub.returns({ label: workspaces[1].name, detail: workspaces[1].uri.fsPath, description: undefined, @@ -116,7 +138,7 @@ suite('Create environment workspace selection tests', () => { const workspace = await pickWorkspaceFolder(); assert.deepEqual(workspace, workspaces[1]); - assert(showQuickPickStub.calledOnce); + assert(showQuickPickWithBackStub.calledOnce); }); test('Multi-workspace scenario with multiple workspaces selected', async () => { @@ -149,7 +171,7 @@ suite('Create environment workspace selection tests', () => { ]; getWorkspaceFoldersStub.returns(workspaces); - showQuickPickStub.returns([ + showQuickPickWithBackStub.returns([ { label: workspaces[1].name, detail: workspaces[1].uri.fsPath, @@ -164,6 +186,6 @@ suite('Create environment workspace selection tests', () => { const workspace = await pickWorkspaceFolder({ allowMultiSelect: true }); assert.deepEqual(workspace, [workspaces[1], workspaces[3]]); - assert(showQuickPickStub.calledOnce); + assert(showQuickPickWithBackStub.calledOnce); }); }); diff --git a/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts index 9c8e1af42b9a..507a2aee88cd 100644 --- a/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts +++ b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts @@ -16,12 +16,14 @@ chaiUse(chaiAsPromised); suite('Create Environments Tests', () => { let showQuickPickStub: sinon.SinonStub; + let showQuickPickWithBackStub: sinon.SinonStub; const disposables: IDisposableRegistry = []; let startedEventTriggered = false; let exitedEventTriggered = false; setup(() => { showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); startedEventTriggered = false; exitedEventTriggered = false; disposables.push( @@ -55,6 +57,25 @@ suite('Create Environments Tests', () => { assert.isTrue(startedEventTriggered); assert.isTrue(exitedEventTriggered); + assert.isTrue(showQuickPickWithBackStub.notCalled); + provider.verifyAll(); + }); + + test('Successful environment creation with Back', async () => { + const provider = typemoq.Mock.ofType(); + provider.setup((p) => p.name).returns(() => 'test'); + provider.setup((p) => p.id).returns(() => 'test-id'); + provider.setup((p) => p.description).returns(() => 'test-description'); + provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + provider.setup((p) => (p as any).then).returns(() => undefined); + + showQuickPickWithBackStub.resolves(provider.object); + + await handleCreateEnvironmentCommand([provider.object], { showBackButton: true }); + + assert.isTrue(startedEventTriggered); + assert.isTrue(exitedEventTriggered); + assert.isTrue(showQuickPickStub.notCalled); provider.verifyAll(); }); @@ -63,9 +84,10 @@ suite('Create Environments Tests', () => { provider.setup((p) => p.name).returns(() => 'test'); provider.setup((p) => p.id).returns(() => 'test-id'); provider.setup((p) => p.description).returns(() => 'test-description'); - provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.reject()); + provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.reject(new Error('test'))); provider.setup((p) => (p as any).then).returns(() => undefined); + showQuickPickStub.resolves(provider.object); await assert.isRejected(handleCreateEnvironmentCommand([provider.object])); assert.isTrue(startedEventTriggered); @@ -73,10 +95,27 @@ suite('Create Environments Tests', () => { provider.verifyAll(); }); + test('Environment creation error with Back', async () => { + const provider = typemoq.Mock.ofType(); + provider.setup((p) => p.name).returns(() => 'test'); + provider.setup((p) => p.id).returns(() => 'test-id'); + provider.setup((p) => p.description).returns(() => 'test-description'); + provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.reject(new Error('test'))); + provider.setup((p) => (p as any).then).returns(() => undefined); + + showQuickPickWithBackStub.resolves(provider.object); + await assert.isRejected(handleCreateEnvironmentCommand([provider.object], { showBackButton: true })); + + assert.isTrue(startedEventTriggered); + assert.isTrue(exitedEventTriggered); + provider.verifyAll(); + }); + test('No providers registered', async () => { await handleCreateEnvironmentCommand([]); assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(showQuickPickWithBackStub.notCalled); assert.isFalse(startedEventTriggered); assert.isFalse(exitedEventTriggered); }); @@ -89,9 +128,11 @@ suite('Create Environments Tests', () => { provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); provider.setup((p) => (p as any).then).returns(() => undefined); + showQuickPickStub.resolves(provider.object); await handleCreateEnvironmentCommand([provider.object]); - assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(showQuickPickStub.calledOnce); + assert.isTrue(showQuickPickWithBackStub.notCalled); assert.isTrue(startedEventTriggered); assert.isTrue(exitedEventTriggered); }); @@ -120,7 +161,106 @@ suite('Create Environments Tests', () => { await handleCreateEnvironmentCommand([provider1.object, provider2.object]); assert.isTrue(showQuickPickStub.calledOnce); + assert.isTrue(showQuickPickWithBackStub.notCalled); assert.isTrue(startedEventTriggered); assert.isTrue(exitedEventTriggered); }); + + test('Single environment creation provider registered with Back', async () => { + const provider = typemoq.Mock.ofType(); + provider.setup((p) => p.name).returns(() => 'test'); + provider.setup((p) => p.id).returns(() => 'test-id'); + provider.setup((p) => p.description).returns(() => 'test-description'); + provider.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + provider.setup((p) => (p as any).then).returns(() => undefined); + + showQuickPickWithBackStub.resolves(provider.object); + await handleCreateEnvironmentCommand([provider.object], { showBackButton: true }); + + assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(showQuickPickWithBackStub.calledOnce); + assert.isTrue(startedEventTriggered); + assert.isTrue(exitedEventTriggered); + }); + + test('Multiple environment creation providers registered with Back', async () => { + const provider1 = typemoq.Mock.ofType(); + provider1.setup((p) => p.name).returns(() => 'test1'); + provider1.setup((p) => p.id).returns(() => 'test-id1'); + provider1.setup((p) => p.description).returns(() => 'test-description1'); + provider1.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + const provider2 = typemoq.Mock.ofType(); + provider2.setup((p) => p.name).returns(() => 'test2'); + provider2.setup((p) => p.id).returns(() => 'test-id2'); + provider2.setup((p) => p.description).returns(() => 'test-description2'); + provider2.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + showQuickPickWithBackStub.resolves({ + id: 'test-id2', + label: 'test2', + description: 'test-description2', + }); + + provider1.setup((p) => (p as any).then).returns(() => undefined); + provider2.setup((p) => (p as any).then).returns(() => undefined); + await handleCreateEnvironmentCommand([provider1.object, provider2.object], { showBackButton: true }); + + assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(showQuickPickWithBackStub.calledOnce); + assert.isTrue(startedEventTriggered); + assert.isTrue(exitedEventTriggered); + }); + + test('User clicked Back', async () => { + const provider1 = typemoq.Mock.ofType(); + provider1.setup((p) => p.name).returns(() => 'test1'); + provider1.setup((p) => p.id).returns(() => 'test-id1'); + provider1.setup((p) => p.description).returns(() => 'test-description1'); + provider1.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + const provider2 = typemoq.Mock.ofType(); + provider2.setup((p) => p.name).returns(() => 'test2'); + provider2.setup((p) => p.id).returns(() => 'test-id2'); + provider2.setup((p) => p.description).returns(() => 'test-description2'); + provider2.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + showQuickPickWithBackStub.returns(Promise.reject(windowApis.MultiStepAction.Back)); + + provider1.setup((p) => (p as any).then).returns(() => undefined); + provider2.setup((p) => (p as any).then).returns(() => undefined); + const result = await handleCreateEnvironmentCommand([provider1.object, provider2.object], { + showBackButton: true, + }); + + assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Back' }); + assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(showQuickPickWithBackStub.calledOnce); + }); + + test('User pressed Escape', async () => { + const provider1 = typemoq.Mock.ofType(); + provider1.setup((p) => p.name).returns(() => 'test1'); + provider1.setup((p) => p.id).returns(() => 'test-id1'); + provider1.setup((p) => p.description).returns(() => 'test-description1'); + provider1.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + const provider2 = typemoq.Mock.ofType(); + provider2.setup((p) => p.name).returns(() => 'test2'); + provider2.setup((p) => p.id).returns(() => 'test-id2'); + provider2.setup((p) => p.description).returns(() => 'test-description2'); + provider2.setup((p) => p.createEnvironment(typemoq.It.isAny())).returns(() => Promise.resolve(undefined)); + + showQuickPickWithBackStub.returns(Promise.reject(windowApis.MultiStepAction.Cancel)); + + provider1.setup((p) => (p as any).then).returns(() => undefined); + provider2.setup((p) => (p as any).then).returns(() => undefined); + const result = await handleCreateEnvironmentCommand([provider1.object, provider2.object], { + showBackButton: true, + }); + + assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Cancel' }); + 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 a7e70061513a..f5267aa634cb 100644 --- a/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts @@ -64,7 +64,7 @@ suite('Conda Creation provider tests', () => { getCondaBaseEnvStub.resolves('/usr/bin/conda'); pickWorkspaceFolderStub.resolves(undefined); - assert.isUndefined(await condaProvider.createEnvironment()); + await assert.isRejected(condaProvider.createEnvironment()); }); test('No python version picked selected', async () => { diff --git a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts index 57e57fb1303d..b56fb158d6ae 100644 --- a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts @@ -66,7 +66,7 @@ suite('venv Creation provider tests', () => { .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) .verifiable(typemoq.Times.never()); - assert.isUndefined(await venvProvider.createEnvironment()); + await assert.isRejected(venvProvider.createEnvironment()); assert.isTrue(pickWorkspaceFolderStub.calledOnce); interpreterQuickPick.verifyAll(); assert.isTrue(pickPackagesToInstallStub.notCalled); @@ -80,7 +80,7 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve(undefined)) .verifiable(typemoq.Times.once()); - assert.isUndefined(await venvProvider.createEnvironment()); + await assert.isRejected(venvProvider.createEnvironment()); assert.isTrue(pickWorkspaceFolderStub.calledOnce); interpreterQuickPick.verifyAll(); @@ -97,11 +97,11 @@ suite('venv Creation provider tests', () => { pickPackagesToInstallStub.resolves(undefined); - assert.isUndefined(await venvProvider.createEnvironment()); + await assert.isRejected(venvProvider.createEnvironment()); assert.isTrue(pickPackagesToInstallStub.calledOnce); }); - test('Create venv with python selected by user', async () => { + test('Create venv with python selected by user no packages selected', async () => { pickWorkspaceFolderStub.resolves(workspace1); interpreterQuickPick @@ -109,10 +109,7 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); - pickPackagesToInstallStub.resolves({ - installType: 'none', - installList: [], - }); + pickPackagesToInstallStub.resolves([]); const deferred = createDeferred(); let _next: undefined | ((value: Output) => void); @@ -172,10 +169,7 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); - pickPackagesToInstallStub.resolves({ - installType: 'none', - installList: [], - }); + pickPackagesToInstallStub.resolves([]); const deferred = createDeferred(); let _error: undefined | ((error: unknown) => void); @@ -229,10 +223,7 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); - pickPackagesToInstallStub.resolves({ - installType: 'none', - installList: [], - }); + pickPackagesToInstallStub.resolves([]); const deferred = createDeferred(); let _next: undefined | ((value: Output) => void); diff --git a/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts index 5ef001c985ad..a0d030853717 100644 --- a/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { assert } from 'chai'; +import { assert, use as chaiUse } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; import * as fs from 'fs-extra'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; @@ -11,9 +12,11 @@ import { pickPackagesToInstall } from '../../../../client/pythonEnvironments/cre import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; import { CreateEnv } from '../../../../client/common/utils/localize'; +chaiUse(chaiAsPromised); + suite('Venv Utils test', () => { let findFilesStub: sinon.SinonStub; - let showQuickPickStub: sinon.SinonStub; + let showQuickPickWithBackStub: sinon.SinonStub; let pathExistsStub: sinon.SinonStub; let readFileStub: sinon.SinonStub; @@ -25,7 +28,7 @@ suite('Venv Utils test', () => { setup(() => { findFilesStub = sinon.stub(workspaceApis, 'findFiles'); - showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); pathExistsStub = sinon.stub(fs, 'pathExists'); readFileStub = sinon.stub(fs, 'readFile'); }); @@ -39,11 +42,8 @@ suite('Venv Utils test', () => { pathExistsStub.resolves(false); const actual = await pickPackagesToInstall(workspace1); - assert.isTrue(showQuickPickStub.notCalled); - assert.deepStrictEqual(actual, { - installType: 'none', - installList: [], - }); + assert.isTrue(showQuickPickWithBackStub.notCalled); + assert.deepStrictEqual(actual, []); }); test('Toml found with no build system', async () => { @@ -52,11 +52,8 @@ suite('Venv Utils test', () => { readFileStub.resolves('[project]\nname = "spam"\nversion = "2020.0.0"\n'); const actual = await pickPackagesToInstall(workspace1); - assert.isTrue(showQuickPickStub.notCalled); - assert.deepStrictEqual(actual, { - installType: 'none', - installList: [], - }); + assert.isTrue(showQuickPickWithBackStub.notCalled); + assert.deepStrictEqual(actual, []); }); test('Toml found with no optional deps', async () => { @@ -67,12 +64,13 @@ suite('Venv Utils test', () => { ); const actual = await pickPackagesToInstall(workspace1); - assert.isTrue(showQuickPickStub.notCalled); - assert.deepStrictEqual(actual, { - installType: 'toml', - installList: [], - source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), - }); + assert.isTrue(showQuickPickWithBackStub.notCalled); + assert.deepStrictEqual(actual, [ + { + installType: 'toml', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + ]); }); test('Toml found with deps, but user presses escape', async () => { @@ -82,11 +80,11 @@ suite('Venv Utils test', () => { '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', ); - showQuickPickStub.resolves(undefined); + showQuickPickWithBackStub.rejects(windowApis.MultiStepAction.Cancel); - const actual = await pickPackagesToInstall(workspace1); + await assert.isRejected(pickPackagesToInstall(workspace1)); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'test' }, { label: 'doc' }], { placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, @@ -96,7 +94,6 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, undefined); }); test('Toml found with dependencies and user selects None', async () => { @@ -106,11 +103,11 @@ suite('Venv Utils test', () => { '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', ); - showQuickPickStub.resolves([]); + showQuickPickWithBackStub.resolves([]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'test' }, { label: 'doc' }], { placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, @@ -120,11 +117,12 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'toml', - installList: [], - source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), - }); + assert.deepStrictEqual(actual, [ + { + installType: 'toml', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + ]); }); test('Toml found with dependencies and user selects One', async () => { @@ -134,11 +132,11 @@ suite('Venv Utils test', () => { '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', ); - showQuickPickStub.resolves([{ label: 'doc' }]); + showQuickPickWithBackStub.resolves([{ label: 'doc' }]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'test' }, { label: 'doc' }], { placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, @@ -148,11 +146,17 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'toml', - installList: ['doc'], - source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), - }); + assert.deepStrictEqual(actual, [ + { + installType: 'toml', + installItem: 'doc', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + { + installType: 'toml', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + ]); }); test('Toml found with dependencies and user selects Few', async () => { @@ -162,11 +166,11 @@ suite('Venv Utils test', () => { '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]\ncov = ["pytest-cov"]', ); - showQuickPickStub.resolves([{ label: 'test' }, { label: 'cov' }]); + showQuickPickWithBackStub.resolves([{ label: 'test' }, { label: 'cov' }]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'test' }, { label: 'doc' }, { label: 'cov' }], { placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, @@ -176,14 +180,28 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'toml', - installList: ['test', 'cov'], - source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), - }); + assert.deepStrictEqual(actual, [ + { + installType: 'toml', + installItem: 'test', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + { + installType: 'toml', + installItem: 'cov', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + { + installType: 'toml', + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }, + ]); }); test('Requirements found, but user presses escape', async () => { + pathExistsStub.resolves(true); + readFileStub.resolves('[project]\nname = "spam"\nversion = "2020.0.0"\n'); + let allow = true; findFilesStub.callsFake(() => { if (allow) { @@ -196,13 +214,12 @@ suite('Venv Utils test', () => { } return Promise.resolve([]); }); - pathExistsStub.resolves(false); - showQuickPickStub.resolves(undefined); + showQuickPickWithBackStub.rejects(windowApis.MultiStepAction.Cancel); - const actual = await pickPackagesToInstall(workspace1); + await assert.isRejected(pickPackagesToInstall(workspace1)); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, @@ -212,8 +229,8 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, undefined); - assert.isTrue(readFileStub.notCalled); + assert.isTrue(readFileStub.calledOnce); + assert.isTrue(pathExistsStub.calledOnce); }); test('Requirements found and user selects None', async () => { @@ -231,11 +248,11 @@ suite('Venv Utils test', () => { }); pathExistsStub.resolves(false); - showQuickPickStub.resolves([]); + showQuickPickWithBackStub.resolves([]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, @@ -245,10 +262,7 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'requirements', - installList: [], - }); + assert.deepStrictEqual(actual, []); assert.isTrue(readFileStub.notCalled); }); @@ -267,11 +281,11 @@ suite('Venv Utils test', () => { }); pathExistsStub.resolves(false); - showQuickPickStub.resolves([{ label: 'requirements.txt' }]); + showQuickPickWithBackStub.resolves([{ label: 'requirements.txt' }]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, @@ -281,10 +295,12 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'requirements', - installList: [path.join(workspace1.uri.fsPath, 'requirements.txt')], - }); + assert.deepStrictEqual(actual, [ + { + installType: 'requirements', + installItem: path.join(workspace1.uri.fsPath, 'requirements.txt'), + }, + ]); assert.isTrue(readFileStub.notCalled); }); @@ -303,11 +319,11 @@ suite('Venv Utils test', () => { }); pathExistsStub.resolves(false); - showQuickPickStub.resolves([{ label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }]); + showQuickPickWithBackStub.resolves([{ label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }]); const actual = await pickPackagesToInstall(workspace1); assert.isTrue( - showQuickPickStub.calledWithExactly( + showQuickPickWithBackStub.calledWithExactly( [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, @@ -317,13 +333,16 @@ suite('Venv Utils test', () => { undefined, ), ); - assert.deepStrictEqual(actual, { - installType: 'requirements', - installList: [ - path.join(workspace1.uri.fsPath, 'dev-requirements.txt'), - path.join(workspace1.uri.fsPath, 'test-requirements.txt'), - ], - }); + assert.deepStrictEqual(actual, [ + { + installType: 'requirements', + installItem: path.join(workspace1.uri.fsPath, 'dev-requirements.txt'), + }, + { + installType: 'requirements', + installItem: path.join(workspace1.uri.fsPath, 'test-requirements.txt'), + }, + ]); assert.isTrue(readFileStub.notCalled); }); });