diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 83eb2dbc4f..0fc468c788 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -34,6 +34,13 @@ export const localhostName = 'localhost'; export const localhostTitle = localize('managePackages.localhost', "localhost"); export const PackageNotFoundError = localize('managePackages.packageNotFound', "Could not find the specified package"); +export const python3DisplayName = 'Python 3'; +export const pysparkDisplayName = 'PySpark'; +export const sparkScalaDisplayName = 'Spark | Scala'; +export const sparkRDisplayName = 'Spark | R'; +export const powershellDisplayName = 'PowerShell'; +export const allKernelsName = 'All Kernels'; + export const visitedNotebooksMementoKey = 'notebooks.visited'; export enum BuiltInCommands { diff --git a/extensions/notebook/src/common/utils.ts b/extensions/notebook/src/common/utils.ts index 50706936ad..3f7cd5c80f 100644 --- a/extensions/notebook/src/common/utils.ts +++ b/extensions/notebook/src/common/utils.ts @@ -294,3 +294,7 @@ function decorate(decorator: (fn: Function, key: string) => Function): Function descriptor[fnKey] = decorator(fn, key); }; } + +export function getDropdownValue(dropdown: azdata.DropDownComponent): string { + return (typeof dropdown.value === 'string') ? dropdown.value : dropdown.value.name; +} diff --git a/extensions/notebook/src/dialog/configurePython/basePage.ts b/extensions/notebook/src/dialog/configurePython/basePage.ts new file mode 100644 index 0000000000..07f85b568d --- /dev/null +++ b/extensions/notebook/src/dialog/configurePython/basePage.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import { ConfigurePythonModel, ConfigurePythonWizard } from './configurePythonWizard'; +import { ApiWrapper } from '../../common/apiWrapper'; + +export abstract class BasePage { + + constructor(protected readonly apiWrapper: ApiWrapper, + protected readonly instance: ConfigurePythonWizard, + protected readonly wizardPage: azdata.window.WizardPage, + protected readonly model: ConfigurePythonModel, + protected readonly view: azdata.ModelView) { + } + + /** + * This method constructs all the elements of the page. + */ + public async abstract initialize(): Promise; + + /** + * This method is called when the user is entering the page. + */ + public async abstract onPageEnter(): Promise; + + /** + * This method is called when the user is leaving the page. + */ + public async abstract onPageLeave(): Promise; +} diff --git a/extensions/notebook/src/dialog/configurePython/configurePathPage.ts b/extensions/notebook/src/dialog/configurePython/configurePathPage.ts new file mode 100644 index 0000000000..e57a2afa44 --- /dev/null +++ b/extensions/notebook/src/dialog/configurePython/configurePathPage.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as azdata from 'azdata'; +import { BasePage } from './basePage'; +import * as nls from 'vscode-nls'; +import { JupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; +import { PythonPathInfo } from '../pythonPathLookup'; +import * as utils from '../../common/utils'; + +const localize = nls.loadMessageBundle(); + +export class ConfigurePathPage extends BasePage { + private readonly BrowseButtonText = localize('configurePython.browseButtonText', "Browse"); + private readonly LocationTextBoxTitle = localize('configurePython.locationTextBoxText', "Python Install Location"); + private readonly SelectFileLabel = localize('configurePython.selectFileLabel', "Select"); + + private pythonLocationDropdown: azdata.DropDownComponent; + private pythonDropdownLoader: azdata.LoadingComponent; + private browseButton: azdata.ButtonComponent; + private newInstallButton: azdata.RadioButtonComponent; + private existingInstallButton: azdata.RadioButtonComponent; + + private usingCustomPath: boolean = false; + + public async initialize(): Promise { + this.pythonLocationDropdown = this.view.modelBuilder.dropDown() + .withProperties({ + value: undefined, + values: [], + width: '100%' + }).component(); + this.pythonDropdownLoader = this.view.modelBuilder.loadingComponent() + .withItem(this.pythonLocationDropdown) + .withProperties({ + loading: false + }) + .component(); + + this.browseButton = this.view.modelBuilder.button() + .withProperties({ + label: this.BrowseButtonText, + width: '70px' + }).component(); + this.browseButton.onDidClick(() => this.handleBrowse()); + + this.createInstallRadioButtons(this.view.modelBuilder, this.model.useExistingPython); + + let formModel = this.view.modelBuilder.formContainer() + .withFormItems([{ + component: this.newInstallButton, + title: localize('configurePython.installationType', "Installation Type") + }, { + component: this.existingInstallButton, + title: '' + }, { + component: this.pythonDropdownLoader, + title: this.LocationTextBoxTitle + }, { + component: this.browseButton, + title: '' + }]).component(); + + await this.view.initializeModel(formModel); + + await this.updatePythonPathsDropdown(this.model.useExistingPython); + + return true; + } + + public async onPageEnter(): Promise { + } + + public async onPageLeave(): Promise { + let pythonLocation = utils.getDropdownValue(this.pythonLocationDropdown); + if (!pythonLocation || pythonLocation.length === 0) { + this.instance.showErrorMessage(this.instance.InvalidLocationMsg); + return false; + } + + this.model.pythonLocation = pythonLocation; + this.model.useExistingPython = !!this.existingInstallButton.checked; + + return true; + } + + private async updatePythonPathsDropdown(useExistingPython: boolean): Promise { + this.pythonDropdownLoader.loading = true; + try { + let pythonPaths: PythonPathInfo[]; + let dropdownValues: azdata.CategoryValue[]; + if (useExistingPython) { + pythonPaths = await this.model.pythonPathsPromise; + if (pythonPaths && pythonPaths.length > 0) { + dropdownValues = pythonPaths.map(path => { + return { + displayName: localize('configurePythyon.dropdownPathLabel', "{0} (Python {1})", path.installDir, path.version), + name: path.installDir + }; + }); + } else { + dropdownValues = [{ + displayName: localize('configurePythyon.noVersionsFound', "No supported Python versions found."), + name: '' + }]; + } + } else { + let defaultPath = JupyterServerInstallation.DefaultPythonLocation; + dropdownValues = [{ + displayName: localize('configurePythyon.defaultPathLabel', "{0} (Default)", defaultPath), + name: defaultPath + }]; + } + + this.usingCustomPath = false; + await this.pythonLocationDropdown.updateProperties({ + value: dropdownValues[0], + values: dropdownValues + }); + } finally { + this.pythonDropdownLoader.loading = false; + } + } + + private createInstallRadioButtons(modelBuilder: azdata.ModelBuilder, useExistingPython: boolean): void { + let buttonGroup = 'installationType'; + this.newInstallButton = modelBuilder.radioButton() + .withProperties({ + name: buttonGroup, + label: localize('configurePython.newInstall', "New Python installation"), + checked: !useExistingPython + }).component(); + this.newInstallButton.onDidClick(() => { + this.updatePythonPathsDropdown(false) + .catch(err => { + this.instance.showErrorMessage(utils.getErrorMessage(err)); + }); + }); + + this.existingInstallButton = modelBuilder.radioButton() + .withProperties({ + name: buttonGroup, + label: localize('configurePython.existingInstall', "Use existing Python installation"), + checked: useExistingPython + }).component(); + this.existingInstallButton.onDidClick(() => { + this.updatePythonPathsDropdown(true) + .catch(err => { + this.instance.showErrorMessage(utils.getErrorMessage(err)); + }); + }); + } + + private async handleBrowse(): Promise { + let options: vscode.OpenDialogOptions = { + defaultUri: vscode.Uri.file(utils.getUserHome()), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: this.SelectFileLabel + }; + + let fileUris: vscode.Uri[] = await this.apiWrapper.showOpenDialog(options); + if (fileUris?.length > 0 && fileUris[0]) { + let existingValues = this.pythonLocationDropdown.values; + let filePath = fileUris[0].fsPath; + let newValue = { + displayName: localize('configurePythyon.customPathLabel', "{0} (Custom)", filePath), + name: filePath + }; + + if (this.usingCustomPath) { + existingValues[0] = newValue; + } else { + existingValues.unshift(newValue); + this.usingCustomPath = true; + } + + await this.pythonLocationDropdown.updateProperties({ + value: existingValues[0], + values: existingValues + }); + } + } +} diff --git a/extensions/notebook/src/dialog/configurePythonDialog.ts b/extensions/notebook/src/dialog/configurePython/configurePythonDialog.ts similarity index 97% rename from extensions/notebook/src/dialog/configurePythonDialog.ts rename to extensions/notebook/src/dialog/configurePython/configurePythonDialog.ts index 38d2ee6bef..664db5c9fa 100644 --- a/extensions/notebook/src/dialog/configurePythonDialog.ts +++ b/extensions/notebook/src/dialog/configurePython/configurePythonDialog.ts @@ -7,12 +7,12 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import * as azdata from 'azdata'; import { promises as fs } from 'fs'; -import * as utils from '../common/utils'; +import * as utils from '../../common/utils'; -import { JupyterServerInstallation } from '../jupyter/jupyterServerInstallation'; -import { ApiWrapper } from '../common/apiWrapper'; -import { Deferred } from '../common/promise'; -import { PythonPathLookup, PythonPathInfo } from './pythonPathLookup'; +import { JupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; +import { ApiWrapper } from '../../common/apiWrapper'; +import { Deferred } from '../../common/promise'; +import { PythonPathLookup, PythonPathInfo } from '../pythonPathLookup'; const localize = nls.loadMessageBundle(); diff --git a/extensions/notebook/src/dialog/configurePython/configurePythonWizard.ts b/extensions/notebook/src/dialog/configurePython/configurePythonWizard.ts new file mode 100644 index 0000000000..19a510b612 --- /dev/null +++ b/extensions/notebook/src/dialog/configurePython/configurePythonWizard.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vscode-nls'; +import * as azdata from 'azdata'; +import { BasePage } from './basePage'; +import { ConfigurePathPage } from './configurePathPage'; +import { PickPackagesPage } from './pickPackagesPage'; +import { JupyterServerInstallation, PythonPkgDetails, PythonInstallSettings } from '../../jupyter/jupyterServerInstallation'; +import * as utils from '../../common/utils'; +import { promises as fs } from 'fs'; +import { Deferred } from '../../common/promise'; +import { PythonPathInfo, PythonPathLookup } from '../pythonPathLookup'; +import { ApiWrapper } from '../../common/apiWrapper'; + +const localize = nls.loadMessageBundle(); + +export interface ConfigurePythonModel { + kernelName: string; + pythonLocation: string; + useExistingPython: boolean; + pythonPathsPromise: Promise; + packagesToInstall: PythonPkgDetails[]; + installation: JupyterServerInstallation; +} + +export class ConfigurePythonWizard { + private readonly InstallButtonText = localize('configurePython.okButtonText', "Install"); + public readonly InvalidLocationMsg = localize('configurePython.invalidLocationMsg', "The specified install location is invalid."); + private readonly PythonNotFoundMsg = localize('configurePython.pythonNotFoundMsg', "No Python installation was found at the specified location."); + + private _wizard: azdata.window.Wizard; + private model: ConfigurePythonModel; + + private _setupComplete: Deferred; + private pythonPathsPromise: Promise; + + constructor(private apiWrapper: ApiWrapper, private jupyterInstallation: JupyterServerInstallation) { + this._setupComplete = new Deferred(); + this.pythonPathsPromise = (new PythonPathLookup()).getSuggestions(); + } + + public get wizard(): azdata.window.Wizard { + return this._wizard; + } + + public get setupComplete(): Promise { + return this._setupComplete.promise; + } + + public async start(kernelName?: string, rejectOnCancel?: boolean, ...args: any[]): Promise { + this.model = { + kernelName: kernelName, + pythonPathsPromise: this.pythonPathsPromise, + installation: this.jupyterInstallation, + useExistingPython: JupyterServerInstallation.getExistingPythonSetting(this.apiWrapper) + }; + + let pages: Map = new Map(); + + let wizardTitle: string; + if (kernelName) { + wizardTitle = localize('configurePython.wizardNameWithKernel', 'Configure Python to run {0} kernel', kernelName); + } else { + wizardTitle = localize('configurePython.wizardNameWithoutKernel', 'Configure Python to run kernels'); + } + this._wizard = azdata.window.createWizard(wizardTitle); + let page0 = azdata.window.createWizardPage(localize('configurePython.page0Name', 'Configure Python Runtime')); + let page1 = azdata.window.createWizardPage(localize('configurePython.page1Name', 'Install Dependencies')); + + page0.registerContent(async (view) => { + let configurePathPage = new ConfigurePathPage(this.apiWrapper, this, page0, this.model, view); + pages.set(0, configurePathPage); + await configurePathPage.initialize(); + await configurePathPage.onPageEnter(); + }); + + page1.registerContent(async (view) => { + let pickPackagesPage = new PickPackagesPage(this.apiWrapper, this, page1, this.model, view); + pages.set(1, pickPackagesPage); + await pickPackagesPage.initialize(); + }); + + this._wizard.doneButton.label = this.InstallButtonText; + this._wizard.cancelButton.onClick(() => { + if (rejectOnCancel) { + this._setupComplete.reject(localize('configurePython.pythonInstallDeclined', "Python installation was declined.")); + } else { + this._setupComplete.resolve(); + } + }); + + this._wizard.onPageChanged(async info => { + let newPage = pages.get(info.newPage); + if (newPage) { + await newPage.onPageEnter(); + } + }); + + this._wizard.registerNavigationValidator(async (info) => { + let lastPage = pages.get(info.lastPage); + let newPage = pages.get(info.newPage); + + // Hit "next" on last page, so handle submit + let nextOnLastPage = !newPage && lastPage instanceof PickPackagesPage; + if (nextOnLastPage) { + return await this.handlePackageInstall(); + } + + if (lastPage) { + let pageValid = await lastPage.onPageLeave(); + if (!pageValid) { + return false; + } + } + + this.clearStatusMessage(); + return true; + }); + + this._wizard.generateScriptButton.hidden = true; + this._wizard.pages = [page0, page1]; + this._wizard.open(); + } + + public async close(): Promise { + await this._wizard.close(); + } + + public showErrorMessage(errorMsg: string) { + this._wizard.message = { + text: errorMsg, + level: azdata.window.MessageLevel.Error + }; + } + + public clearStatusMessage() { + this._wizard.message = undefined; + } + + private async handlePackageInstall(): Promise { + let pythonLocation = this.model.pythonLocation; + let useExistingPython = this.model.useExistingPython; + try { + let isValid = await this.isFileValid(pythonLocation); + if (!isValid) { + return false; + } + + if (useExistingPython) { + let exePath = JupyterServerInstallation.getPythonExePath(pythonLocation, true); + let pythonExists = await utils.exists(exePath); + if (!pythonExists) { + this.showErrorMessage(this.PythonNotFoundMsg); + return false; + } + } + } catch (err) { + this.showErrorMessage(utils.getErrorMessage(err)); + return false; + } + + // Don't wait on installation, since there's currently no Cancel functionality + let installSettings: PythonInstallSettings = { + installPath: pythonLocation, + existingPython: useExistingPython, + specificPackages: this.model.packagesToInstall + }; + this.jupyterInstallation.startInstallProcess(false, installSettings) + .then(() => { + this._setupComplete.resolve(); + }) + .catch(err => { + this._setupComplete.reject(utils.getErrorMessage(err)); + }); + + return true; + } + + private async isFileValid(pythonLocation: string): Promise { + try { + const stats = await fs.stat(pythonLocation); + if (stats.isFile()) { + this.showErrorMessage(this.InvalidLocationMsg); + return false; + } + } catch (err) { + // Ignore error if folder doesn't exist, since it will be + // created during installation + if (err.code !== 'ENOENT') { + this.showErrorMessage(err.message); + return false; + } + } + return true; + } +} diff --git a/extensions/notebook/src/dialog/configurePython/pickPackagesPage.ts b/extensions/notebook/src/dialog/configurePython/pickPackagesPage.ts new file mode 100644 index 0000000000..4b2ebbb450 --- /dev/null +++ b/extensions/notebook/src/dialog/configurePython/pickPackagesPage.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as nls from 'vscode-nls'; +import { BasePage } from './basePage'; +import { JupyterServerInstallation, PythonPkgDetails } from '../../jupyter/jupyterServerInstallation'; +import { python3DisplayName, pysparkDisplayName, sparkScalaDisplayName, sparkRDisplayName, powershellDisplayName, allKernelsName } from '../../common/constants'; +import { getDropdownValue } from '../../common/utils'; + +const localize = nls.loadMessageBundle(); + +export class PickPackagesPage extends BasePage { + private kernelLabel: azdata.TextComponent | undefined; + private kernelDropdown: azdata.DropDownComponent | undefined; + private requiredPackagesTable: azdata.DeclarativeTableComponent; + private packageTableSpinner: azdata.LoadingComponent; + + private installedPackagesPromise: Promise; + private installedPackages: PythonPkgDetails[]; + + public async initialize(): Promise { + if (this.model.kernelName) { + // Wizard was started for a specific kernel, so don't populate any other options + this.kernelLabel = this.view.modelBuilder.text().withProperties({ + value: this.model.kernelName + }).component(); + } else { + let dropdownValues = [python3DisplayName, pysparkDisplayName, sparkScalaDisplayName, sparkRDisplayName, powershellDisplayName, allKernelsName]; + this.kernelDropdown = this.view.modelBuilder.dropDown().withProperties({ + value: dropdownValues[0], + values: dropdownValues, + width: '300px' + }).component(); + this.kernelDropdown.onValueChanged(async value => { + await this.updateRequiredPackages(value.selected); + }); + } + + this.requiredPackagesTable = this.view.modelBuilder.declarativeTable().withProperties({ + columns: [{ + displayName: localize('configurePython.pkgNameColumn', "Name"), + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '200px' + }, { + displayName: localize('configurePython.existingVersionColumn', "Existing Version"), + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '200px' + }, { + displayName: localize('configurePython.requiredVersionColumn', "Required Version"), + valueType: azdata.DeclarativeDataType.string, + isReadOnly: true, + width: '200px' + }], + data: [[]] + }).component(); + + this.packageTableSpinner = this.view.modelBuilder.loadingComponent().withItem(this.requiredPackagesTable).component(); + + let formModel = this.view.modelBuilder.formContainer() + .withFormItems([{ + component: this.kernelDropdown ?? this.kernelLabel, + title: localize('configurePython.kernelLabel', "Kernel") + }, { + component: this.packageTableSpinner, + title: localize('configurePython.requiredDependencies', "Install required kernel dependencies") + }]).component(); + await this.view.initializeModel(formModel); + return true; + } + + public async onPageEnter(): Promise { + let pythonExe = JupyterServerInstallation.getPythonExePath(this.model.pythonLocation, this.model.useExistingPython); + this.installedPackagesPromise = this.model.installation.getInstalledPipPackages(pythonExe); + this.installedPackages = undefined; + + if (this.kernelDropdown) { + if (this.model.kernelName) { + this.kernelDropdown.value = this.model.kernelName; + } else { + this.model.kernelName = getDropdownValue(this.kernelDropdown); + } + } + await this.updateRequiredPackages(this.model.kernelName); + } + + public async onPageLeave(): Promise { + return true; + } + + private async updateRequiredPackages(kernelName: string): Promise { + this.packageTableSpinner.loading = true; + try { + let pkgVersionMap = new Map(); + + // Fetch list of required packages for the specified kernel + let requiredPackages = JupyterServerInstallation.getRequiredPackagesForKernel(kernelName); + requiredPackages.forEach(pkg => { + pkgVersionMap.set(pkg.name, { currentVersion: undefined, newVersion: pkg.version }); + }); + + // For each required package, check if there is another version of that package already installed + if (!this.installedPackages) { + this.installedPackages = await this.installedPackagesPromise; + } + this.installedPackages.forEach(pkg => { + let info = pkgVersionMap.get(pkg.name); + if (info) { + info.currentVersion = pkg.version; + pkgVersionMap.set(pkg.name, info); + } + }); + + if (pkgVersionMap.size > 0) { + let packageData = []; + for (let [key, value] of pkgVersionMap.entries()) { + packageData.push([key, value.currentVersion ?? '-', value.newVersion]); + } + this.requiredPackagesTable.data = packageData; + this.model.packagesToInstall = requiredPackages; + } else { + this.instance.showErrorMessage(localize('msgUnsupportedKernel', "Could not retrieve packages for unsupported kernel {0}", kernelName)); + this.requiredPackagesTable.data = [['-', '-', '-']]; + this.model.packagesToInstall = undefined; + } + } finally { + this.packageTableSpinner.loading = false; + } + } +} diff --git a/extensions/notebook/src/jupyter/jupyterController.ts b/extensions/notebook/src/jupyter/jupyterController.ts index 7873066c33..4625100492 100644 --- a/extensions/notebook/src/jupyter/jupyterController.ts +++ b/extensions/notebook/src/jupyter/jupyterController.ts @@ -22,7 +22,7 @@ import { ApiWrapper } from '../common/apiWrapper'; import { LocalJupyterServerManager, ServerInstanceFactory } from './jupyterServerManager'; import { NotebookCompletionItemProvider } from '../intellisense/completionItemProvider'; import { JupyterNotebookProvider } from './jupyterNotebookProvider'; -import { ConfigurePythonDialog } from '../dialog/configurePythonDialog'; +import { ConfigurePythonWizard } from '../dialog/configurePython/configurePythonWizard'; import CodeAdapter from '../prompts/adapter'; import { ManagePackagesDialog } from '../dialog/managePackages/managePackagesDialog'; import { IPackageManageProvider } from '../types'; @@ -30,6 +30,7 @@ import { LocalPipPackageManageProvider } from './localPipPackageManageProvider'; import { LocalCondaPackageManageProvider } from './localCondaPackageManageProvider'; import { ManagePackagesDialogModel, ManagePackageDialogOptions } from '../dialog/managePackages/managePackagesDialogModel'; import { PiPyClient } from './pipyClient'; +import { ConfigurePythonDialog } from '../dialog/configurePython/configurePythonDialog'; let untitledCounter = 0; @@ -250,10 +251,21 @@ export class JupyterController implements vscode.Disposable { } public doConfigurePython(jupyterInstaller: JupyterServerInstallation): void { - let pythonDialog = new ConfigurePythonDialog(this.apiWrapper, jupyterInstaller); - pythonDialog.showDialog().catch((err: any) => { - this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); - }); + let enablePreviewFeatures = this.apiWrapper.getConfiguration('workbench').get('enablePreviewFeatures'); + if (enablePreviewFeatures) { + let pythonWizard = new ConfigurePythonWizard(this.apiWrapper, jupyterInstaller); + pythonWizard.start().catch((err: any) => { + this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); + }); + pythonWizard.setupComplete.catch((err: any) => { + this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); + }); + } else { + let pythonDialog = new ConfigurePythonDialog(this.apiWrapper, jupyterInstaller); + pythonDialog.showDialog().catch((err: any) => { + this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); + }); + } } public get jupyterInstallation() { diff --git a/extensions/notebook/src/jupyter/jupyterNotebookManager.ts b/extensions/notebook/src/jupyter/jupyterNotebookManager.ts index 93984d4cfc..2cacd13ebc 100644 --- a/extensions/notebook/src/jupyter/jupyterNotebookManager.ts +++ b/extensions/notebook/src/jupyter/jupyterNotebookManager.ts @@ -20,8 +20,8 @@ export class JupyterNotebookManager implements nb.NotebookManager, vscode.Dispos this._sessionManager = sessionManager || new JupyterSessionManager(pythonEnvVarPath); this._serverManager.onServerStarted(() => { this.setServerSettings(this._serverManager.serverSettings); + this._sessionManager.installation = this._serverManager.instanceOptions.install; }); - } public get contentManager(): nb.ContentManager { return undefined; diff --git a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts index fd3cb5e578..b9abf6f01e 100644 --- a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts +++ b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts @@ -17,9 +17,10 @@ import * as constants from '../common/constants'; import * as utils from '../common/utils'; import { OutputChannel, ConfigurationTarget, window } from 'vscode'; import { Deferred } from '../common/promise'; -import { ConfigurePythonDialog } from '../dialog/configurePythonDialog'; +import { ConfigurePythonWizard } from '../dialog/configurePython/configurePythonWizard'; import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; import CodeAdapter from '../prompts/adapter'; +import { ConfigurePythonDialog } from '../dialog/configurePython/configurePythonDialog'; const localize = nls.loadMessageBundle(); const msgInstallPkgProgress = localize('msgInstallPkgProgress', "Notebook dependencies installation is in progress"); @@ -39,10 +40,15 @@ function msgDependenciesInstallationFailed(errorMessage: string): string { retur function msgDownloadPython(platform: string, pythonDownloadUrl: string): string { return localize('msgDownloadPython', "Downloading local python for platform: {0} to {1}", platform, pythonDownloadUrl); } function msgPackageRetrievalFailed(errorMessage: string): string { return localize('msgPackageRetrievalFailed', "Encountered an error when trying to retrieve list of installed packages: {0}", errorMessage); } +export interface PythonInstallSettings { + installPath: string; + existingPython: boolean; + specificPackages?: PythonPkgDetails[]; +} export interface IJupyterServerInstallation { installCondaPackages(packages: PythonPkgDetails[], useMinVersion: boolean): Promise; configurePackagePaths(): Promise; - startInstallProcess(forceInstall: boolean, installSettings?: { installPath: string, existingPython: boolean }): Promise; + startInstallProcess(forceInstall: boolean, installSettings?: PythonInstallSettings): Promise; getInstalledPipPackages(): Promise; getInstalledCondaPackages(): Promise; uninstallCondaPackages(packages: PythonPkgDetails[]): Promise; @@ -66,7 +72,6 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { private _pythonInstallationPath: string; private _pythonExecutable: string; - private _pythonPackageDir: string; private _usingExistingPython: boolean; private _usingConda: boolean; @@ -104,6 +109,8 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { private readonly _expectedCondaPipPackages = this._commonPipPackages; private readonly _expectedCondaPackages: PythonPkgDetails[]; + private _kernelSetupCache: Map; + constructor(extensionPath: string, outputChannel: OutputChannel, apiWrapper: ApiWrapper, pythonInstallationPath?: string) { this.extensionPath = extensionPath; this.outputChannel = outputChannel; @@ -120,9 +127,11 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { } else { this._expectedCondaPackages = this._commonPackages; } + + this._kernelSetupCache = new Map(); } - private async installDependencies(backgroundOperation: azdata.BackgroundOperation, forceInstall: boolean): Promise { + private async installDependencies(backgroundOperation: azdata.BackgroundOperation, forceInstall: boolean, specificPackages?: PythonPkgDetails[]): Promise { if (!(await utils.exists(this._pythonExecutable)) || forceInstall || this._usingExistingPython) { window.showInformationMessage(msgInstallPkgStart); @@ -132,12 +141,7 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { try { await this.installPythonPackage(backgroundOperation, this._usingExistingPython, this._pythonInstallationPath, this.outputChannel); - - if (this._usingExistingPython) { - await this.upgradePythonPackages(false, forceInstall); - } else { - await this.installOfflinePipDependencies(); - } + await this.upgradePythonPackages(false, forceInstall, specificPackages); } catch (err) { this.outputChannel.appendLine(msgDependenciesInstallationFailed(utils.getErrorMessage(err))); throw err; @@ -282,19 +286,13 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { ? this._pythonInstallationPath : path.join(this._pythonInstallationPath, constants.pythonBundleVersion); - if (this._usingExistingPython) { - this._pythonPackageDir = undefined; - } else { - this._pythonPackageDir = path.join(pythonSourcePath, 'offlinePackages'); - } - // Update python paths and properties to reference user's local python. let pythonBinPathSuffix = process.platform === constants.winPlatform ? '' : 'bin'; this._pythonExecutable = JupyterServerInstallation.getPythonExePath(this._pythonInstallationPath, this._usingExistingPython); this.pythonBinPath = path.join(pythonSourcePath, pythonBinPathSuffix); - this._usingConda = this.checkCondaExists(); + this._usingConda = this.isCondaInstalled(); // Store paths to python libraries required to run jupyter. this.pythonEnvVarPath = process.env['PATH']; @@ -360,7 +358,7 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { * @param installSettings Optional parameter that specifies where to install python, and whether the install targets an existing python install. * The previous python path (or the default) is used if a new path is not specified. */ - public async startInstallProcess(forceInstall: boolean, installSettings?: { installPath: string, existingPython: boolean }): Promise { + public async startInstallProcess(forceInstall: boolean, installSettings?: PythonInstallSettings): Promise { let isPythonRunning: boolean; if (installSettings) { isPythonRunning = await this.isPythonRunning(installSettings.installPath, installSettings.existingPython); @@ -399,7 +397,7 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { description: msgTaskName, isCancelable: false, operation: op => { - this.installDependencies(op, forceInstall) + this.installDependencies(op, forceInstall, installSettings?.specificPackages) .then(async () => { await updateConfig(); this._installCompletion.resolve(); @@ -427,28 +425,45 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { /** * Opens a dialog for configuring the installation path for the Notebook Python dependencies. */ - public async promptForPythonInstall(): Promise { + public async promptForPythonInstall(kernelDisplayName: string): Promise { if (!JupyterServerInstallation.isPythonInstalled(this.apiWrapper)) { - let pythonDialog = new ConfigurePythonDialog(this.apiWrapper, this); - return pythonDialog.showDialog(true); + let enablePreviewFeatures = this.apiWrapper.getConfiguration('workbench').get('enablePreviewFeatures'); + if (enablePreviewFeatures) { + let pythonWizard = new ConfigurePythonWizard(this.apiWrapper, this); + await pythonWizard.start(kernelDisplayName, true); + return pythonWizard.setupComplete; + } else { + let pythonDialog = new ConfigurePythonDialog(this.apiWrapper, this); + return pythonDialog.showDialog(true); + } } } /** * Prompts user to upgrade certain python packages if they're below the minimum expected version. */ - public async promptForPackageUpgrade(): Promise { + public async promptForPackageUpgrade(kernelName: string): Promise { if (this._installInProgress) { this.apiWrapper.showInfoMessage(msgWaitingForInstall); return this._installCompletion.promise; } + let requiredPackages: PythonPkgDetails[]; + let enablePreviewFeatures = this.apiWrapper.getConfiguration('workbench').get('enablePreviewFeatures'); + if (enablePreviewFeatures) { + if (this._kernelSetupCache.get(kernelName)) { + return; + } + requiredPackages = JupyterServerInstallation.getRequiredPackagesForKernel(kernelName); + } + this._installInProgress = true; this._installCompletion = new Deferred(); - this.upgradePythonPackages(true, false) + this.upgradePythonPackages(true, false, requiredPackages) .then(() => { this._installCompletion.resolve(); this._installInProgress = false; + this._kernelSetupCache.set(kernelName, true); }) .catch(err => { let errorMsg = msgDependenciesInstallationFailed(utils.getErrorMessage(err)); @@ -458,10 +473,14 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { return this._installCompletion.promise; } - private async upgradePythonPackages(promptForUpgrade: boolean, forceInstall: boolean): Promise { + private async upgradePythonPackages(promptForUpgrade: boolean, forceInstall: boolean, specificPackages?: PythonPkgDetails[]): Promise { let expectedCondaPackages: PythonPkgDetails[]; let expectedPipPackages: PythonPkgDetails[]; - if (this._usingConda) { + if (specificPackages) { + // Always install generic packages with pip, since conda may not have them. + expectedCondaPackages = []; + expectedPipPackages = specificPackages; + } else if (this._usingConda) { expectedCondaPackages = this._expectedCondaPackages; expectedPipPackages = this._expectedCondaPipPackages; } else { @@ -510,9 +529,12 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { if (promptForUpgrade) { doUpgrade = await this._prompter.promptSingle({ type: QuestionTypes.confirm, - message: localize('confirmPackageUpgrade', "Some installed python packages need to be upgraded. Would you like to upgrade them now?"), + message: localize('confirmPackageUpgrade', "Some required python packages need to be installed. Would you like to install them now?"), default: true }); + if (!doUpgrade) { + throw new Error(localize('configurePython.packageInstallDeclined', "Package installation was declined.")); + } } else { doUpgrade = true; } @@ -563,9 +585,17 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { } } - public async getInstalledPipPackages(): Promise { + public async getInstalledPipPackages(pythonExePath?: string): Promise { try { - let cmd = `"${this.pythonExecutable}" -m pip list --format=json`; + if (pythonExePath) { + if (!fs.existsSync(pythonExePath)) { + return []; + } + } else if (!JupyterServerInstallation.isPythonInstalled(this.apiWrapper)) { + return []; + } + + let cmd = `"${pythonExePath ?? this.pythonExecutable}" -m pip list --format=json`; let packagesInfo = await this.executeBufferedCommand(cmd); let packagesResult: PythonPkgDetails[] = []; if (packagesInfo) { @@ -598,6 +628,10 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { public async getInstalledCondaPackages(): Promise { try { + if (!this.isCondaInstalled()) { + return []; + } + let condaExe = this.getCondaExePath(); let cmd = `"${condaExe}" list --json`; let packagesInfo = await this.executeBufferedCommand(cmd); @@ -637,32 +671,6 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { return this.executeStreamedCommand(cmd); } - private async installOfflinePipDependencies(): Promise { - // Skip this step if using existing python, since this is for our provided package - if (!this._usingExistingPython && process.platform === constants.winPlatform) { - this.outputChannel.show(true); - this.outputChannel.appendLine(localize('msgInstallStart', "Installing required packages to run Notebooks...")); - - let requirements = path.join(this._pythonPackageDir, 'requirements.txt'); - let installJupyterCommand = `"${this._pythonExecutable}" -m pip install --no-index -r "${requirements}" --find-links "${this._pythonPackageDir}" --no-warn-script-location`; - await this.executeStreamedCommand(installJupyterCommand); - - // Force reinstall pip to update shebangs in pip*.exe files - installJupyterCommand = `"${this._pythonExecutable}" -m pip install --force-reinstall --no-index pip --find-links "${this._pythonPackageDir}" --no-warn-script-location`; - await this.executeStreamedCommand(installJupyterCommand); - - fs.remove(this._pythonPackageDir, (err: Error) => { - if (err) { - this.outputChannel.appendLine(err.message); - } - }); - - this.outputChannel.appendLine(localize('msgJupyterInstallDone', "... Jupyter installation complete.")); - } else { - return Promise.resolve(); - } - } - public async executeStreamedCommand(command: string): Promise { await utils.executeStreamedCommand(command, { env: this.execOptions.env }, this.outputChannel); } @@ -691,7 +699,7 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { return this._usingConda; } - private checkCondaExists(): boolean { + private isCondaInstalled(): boolean { if (!this._usingExistingPython) { return false; } @@ -800,6 +808,55 @@ export class JupyterServerInstallation implements IJupyterServerInstallation { return undefined; } + + public static getRequiredPackagesForKernel(kernelName: string): PythonPkgDetails[] { + let packages = [{ + name: 'jupyter', + version: '1.0.0' + }]; + switch (kernelName) { + case constants.python3DisplayName: + break; + case constants.pysparkDisplayName: + case constants.sparkScalaDisplayName: + case constants.sparkRDisplayName: + packages.push({ + name: 'sparkmagic', + version: '0.12.9' + }, { + name: 'pandas', + version: '0.24.2' + }, { + name: 'prose-codeaccelerator', + version: '1.3.0' + }); + break; + case constants.powershellDisplayName: + packages.push({ + name: 'powershell-kernel', + version: '0.1.3' + }); + break; + case constants.allKernelsName: + packages.push({ + name: 'sparkmagic', + version: '0.12.9' + }, { + name: 'pandas', + version: '0.24.2' + }, { + name: 'prose-codeaccelerator', + version: '1.3.0' + }, { + name: 'powershell-kernel', + version: '0.1.3' + }); + break; + default: + return undefined; + } + return packages; + } } export interface PythonPkgDetails { diff --git a/extensions/notebook/src/jupyter/jupyterServerManager.ts b/extensions/notebook/src/jupyter/jupyterServerManager.ts index fb51954c30..bb21573a15 100644 --- a/extensions/notebook/src/jupyter/jupyterServerManager.ts +++ b/extensions/notebook/src/jupyter/jupyterServerManager.ts @@ -56,16 +56,15 @@ export class LocalJupyterServerManager implements nb.ServerManager, vscode.Dispo return this.options && this.options.jupyterInstallation; } - public async startServer(): Promise { + public async startServer(kernelSpec: nb.IKernelSpec): Promise { try { if (!this._jupyterServer) { - this._jupyterServer = await this.doStartServer(); + this._jupyterServer = await this.doStartServer(kernelSpec); this.options.extensionContext.subscriptions.push(this); let partialSettings = LocalJupyterServerManager.getLocalConnectionSettings(this._jupyterServer.uri); this._serverSettings = partialSettings; this._onServerStarted.fire(); } - } catch (error) { // this is caught and notified up the stack, no longer showing a message here throw error; @@ -107,10 +106,10 @@ export class LocalJupyterServerManager implements nb.ServerManager, vscode.Dispo return this.options.documentPath; } - private async doStartServer(): Promise { // We can't find or create servers until the installation is complete + private async doStartServer(kernelSpec: nb.IKernelSpec): Promise { // We can't find or create servers until the installation is complete let installation = this.options.jupyterInstallation; - await installation.promptForPythonInstall(); - await installation.promptForPackageUpgrade(); + await installation.promptForPythonInstall(kernelSpec.display_name); + await installation.promptForPackageUpgrade(kernelSpec.display_name); this._apiWrapper.setCommandContext(CommandContext.NotebookPythonInstalled, true); // Calculate the path to use as the notebook-dir for Jupyter based on the path of the uri of the diff --git a/extensions/notebook/src/jupyter/jupyterSessionManager.ts b/extensions/notebook/src/jupyter/jupyterSessionManager.ts index e8eee2adea..b58a868eb2 100644 --- a/extensions/notebook/src/jupyter/jupyterSessionManager.ts +++ b/extensions/notebook/src/jupyter/jupyterSessionManager.ts @@ -15,6 +15,7 @@ const localize = nls.loadMessageBundle(); import { JupyterKernel } from './jupyterKernel'; import { Deferred } from '../common/promise'; +import { JupyterServerInstallation } from './jupyterServerInstallation'; const configBase = { 'kernel_python_credentials': { @@ -66,6 +67,7 @@ export class JupyterSessionManager implements nb.SessionManager { private _isReady: boolean; private _sessionManager: Session.IManager; private static _sessions: JupyterSession[] = []; + private _installation: JupyterServerInstallation; constructor(private _pythonEnvVarPath?: string) { this._isReady = false; @@ -84,6 +86,12 @@ export class JupyterSessionManager implements nb.SessionManager { }); } + public set installation(installation: JupyterServerInstallation) { + this._installation = installation; + JupyterSessionManager._sessions.forEach(session => { + session.installation = installation; + }); + } public get isReady(): boolean { return this._isReady; } @@ -126,7 +134,7 @@ export class JupyterSessionManager implements nb.SessionManager { return Promise.reject(new Error(localize('errorStartBeforeReady', "Cannot start a session, the manager is not yet initialized"))); } let sessionImpl = await this._sessionManager.startNew(options); - let jupyterSession = new JupyterSession(sessionImpl, skipSettingEnvironmentVars, this._pythonEnvVarPath); + let jupyterSession = new JupyterSession(sessionImpl, this._installation, skipSettingEnvironmentVars, this._pythonEnvVarPath); await jupyterSession.messagesComplete; let index = JupyterSessionManager._sessions.findIndex(session => session.path === options.path); if (index > -1) { @@ -173,7 +181,7 @@ export class JupyterSession implements nb.ISession { private _kernel: nb.IKernel; private _messagesComplete: Deferred = new Deferred(); - constructor(private sessionImpl: Session.ISession, skipSettingEnvironmentVars?: boolean, private _pythonEnvVarPath?: string) { + constructor(private sessionImpl: Session.ISession, private _installation: JupyterServerInstallation, skipSettingEnvironmentVars?: boolean, private _pythonEnvVarPath?: string) { this.setEnvironmentVars(skipSettingEnvironmentVars).catch(error => { console.error(`Unexpected exception setting Jupyter Session variables : ${error}`); // We don't want callers to hang forever waiting - it's better to continue on even if we weren't @@ -221,7 +229,20 @@ export class JupyterSession implements nb.ISession { return this._messagesComplete.promise; } + public set installation(installation: JupyterServerInstallation) { + this._installation = installation; + } + public async changeKernel(kernelInfo: nb.IKernelSpec): Promise { + if (this._installation) { + try { + await this._installation.promptForPackageUpgrade(kernelInfo.display_name); + } catch (err) { + // Have to swallow the error here to prevent hangs when changing back to the old kernel. + console.error(err.toString()); + return this._kernel; + } + } // For now, Jupyter implementation handles disposal etc. so we can just // null out our kernel and let the changeKernel call handle this this._kernel = undefined; diff --git a/extensions/notebook/src/test/common.ts b/extensions/notebook/src/test/common.ts index 8582847616..b1190f4d9e 100644 --- a/extensions/notebook/src/test/common.ts +++ b/extensions/notebook/src/test/common.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { IServerInstance } from '../jupyter/common'; @@ -255,3 +256,160 @@ export class FutureStub implements Kernel.IFuture { } } //#endregion + +//#region test modelView components +class TestComponentBase implements azdata.Component { + id: string = ''; + updateProperties(properties: { [key: string]: any; }): Thenable { + Object.assign(this, properties); + return Promise.resolve(); + } + updateProperty(key: string, value: any): Thenable { + throw new Error('Method not implemented'); + } + updateCssStyles(cssStyles: { [key: string]: string; }): Thenable { + throw new Error('Method not implemented'); + } + onValidityChanged: vscode.Event = undefined; + valid: boolean = true; + validate(): Thenable { + return Promise.resolve(true); + } + focus(): Thenable { + return Promise.resolve(); + } +} + +class TestDropdownComponent extends TestComponentBase implements azdata.DropDownComponent { + constructor(private onClick: vscode.EventEmitter) { + super(); + } + onValueChanged: vscode.Event = this.onClick.event; +} + +class TestDeclarativeTableComponent extends TestComponentBase implements azdata.DeclarativeTableComponent { + constructor(private onClick: vscode.EventEmitter) { + super(); + } + onDataChanged: vscode.Event = this.onClick.event; + data: any[][]; + columns: azdata.DeclarativeTableColumn[]; +} + +class TestButtonComponent extends TestComponentBase implements azdata.ButtonComponent { + constructor(private onClick: vscode.EventEmitter) { + super(); + } + onDidClick: vscode.Event = this.onClick.event; +} + +class TestRadioButtonComponent extends TestComponentBase implements azdata.RadioButtonComponent { + constructor(private onClick: vscode.EventEmitter) { + super(); + } + onDidClick: vscode.Event = this.onClick.event; +} + +class TestTextComponent extends TestComponentBase implements azdata.TextComponent { +} + +class TestLoadingComponent extends TestComponentBase implements azdata.LoadingComponent { + loading: boolean; + component: azdata.Component; +} + +class TestFormContainer extends TestComponentBase implements azdata.FormContainer { + items: azdata.Component[] = []; + clearItems(): void { + } + addItems(itemConfigs: azdata.Component[], itemLayout?: azdata.FormItemLayout): void { + } + addItem(component: azdata.Component, itemLayout?: azdata.FormItemLayout): void { + } + insertItem(component: azdata.Component, index: number, itemLayout?: azdata.FormItemLayout): void { + } + removeItem(component: azdata.Component): boolean { + return true; + } + setLayout(layout: azdata.FormLayout): void { + } + setItemLayout(component: azdata.Component, layout: azdata.FormItemLayout): void { + } +} + +class TestComponentBuilder implements azdata.ComponentBuilder { + constructor(private _component: T) { + } + component(): T { + return this._component; + } + withProperties(properties: U): azdata.ComponentBuilder { + this._component.updateProperties(properties); + return this; + } + withValidation(validation: (component: T) => boolean): azdata.ComponentBuilder { + return this; + } +} + +class TestLoadingBuilder extends TestComponentBuilder implements azdata.LoadingComponentBuilder { + withItem(component: azdata.Component): azdata.LoadingComponentBuilder { + this.component().component = component; + return this; + } +} + +export function createViewContext(): TestContext { + let onClick: vscode.EventEmitter = new vscode.EventEmitter(); + + let form: azdata.FormContainer = new TestFormContainer(); + let textBuilder: azdata.ComponentBuilder = new TestComponentBuilder(new TestTextComponent()); + let buttonBuilder: azdata.ComponentBuilder = new TestComponentBuilder(new TestButtonComponent(onClick)); + let radioButtonBuilder: azdata.ComponentBuilder = new TestComponentBuilder(new TestRadioButtonComponent(onClick)); + let declarativeTableBuilder: azdata.ComponentBuilder = new TestComponentBuilder(new TestDeclarativeTableComponent(onClick)); + let loadingBuilder: azdata.LoadingComponentBuilder = new TestLoadingBuilder(new TestLoadingComponent()); + let dropdownBuilder: azdata.ComponentBuilder = new TestComponentBuilder(new TestDropdownComponent(onClick)); + + let formBuilder: azdata.FormBuilder = Object.assign({}, { + component: () => form, + addFormItem: () => { }, + insertFormItem: () => { }, + removeFormItem: () => true, + addFormItems: () => { }, + withFormItems: () => formBuilder, + withProperties: () => formBuilder, + withValidation: () => formBuilder, + withItems: () => formBuilder, + withLayout: () => formBuilder + }); + + let view: azdata.ModelView = { + onClosed: undefined!, + connection: undefined!, + serverInfo: undefined!, + valid: true, + onValidityChanged: undefined!, + validate: undefined!, + initializeModel: () => { return Promise.resolve(); }, + modelBuilder: { + radioButton: () => radioButtonBuilder, + text: () => textBuilder, + button: () => buttonBuilder, + dropDown: () => dropdownBuilder, + declarativeTable: () => declarativeTableBuilder, + formContainer: () => formBuilder, + loadingComponent: () => loadingBuilder + } + }; + + return { + view: view, + onClick: onClick, + }; +} + +export interface TestContext { + view: azdata.ModelView; + onClick: vscode.EventEmitter; +} +//#endregion diff --git a/extensions/notebook/src/test/configurePython.test.ts b/extensions/notebook/src/test/configurePython.test.ts new file mode 100644 index 0000000000..4cded30d7f --- /dev/null +++ b/extensions/notebook/src/test/configurePython.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as TypeMoq from 'typemoq'; +import { ApiWrapper } from '../common/apiWrapper'; +import { ConfigurePythonWizard, ConfigurePythonModel } from '../dialog/configurePython/configurePythonWizard'; +import { JupyterServerInstallation } from '../jupyter/jupyterServerInstallation'; +import { ConfigurePathPage } from '../dialog/configurePython/configurePathPage'; +import * as should from 'should'; +import { PickPackagesPage } from '../dialog/configurePython/pickPackagesPage'; +import { python3DisplayName, allKernelsName } from '../common/constants'; +import { TestContext, createViewContext } from './common'; + +describe('Configure Python Wizard', function () { + let apiWrapper: ApiWrapper = new ApiWrapper(); + let testWizard: ConfigurePythonWizard; + let viewContext: TestContext; + let testInstallation: JupyterServerInstallation; + + beforeEach(() => { + let mockInstall = TypeMoq.Mock.ofType(JupyterServerInstallation); + mockInstall.setup(i => i.getInstalledPipPackages(TypeMoq.It.isAnyString())).returns(() => Promise.resolve([])); + testInstallation = mockInstall.object; + + let mockWizard = TypeMoq.Mock.ofType(ConfigurePythonWizard); + mockWizard.setup(w => w.showErrorMessage(TypeMoq.It.isAnyString())); + testWizard = mockWizard.object; + + viewContext = createViewContext(); + }); + + // These wizard tests are disabled due to errors with disposable objects + // + // it('Start wizard test', async () => { + // let wizard = new ConfigurePythonWizard(apiWrapper, testInstallation); + // await wizard.start(); + // await wizard.close(); + // await should(wizard.setupComplete).be.resolved(); + // }); + + // it('Reject setup on cancel test', async () => { + // let wizard = new ConfigurePythonWizard(apiWrapper, testInstallation); + // await wizard.start(undefined, true); + // await wizard.close(); + // await should(wizard.setupComplete).be.rejected(); + // }); + + // it('Error message test', async () => { + // let wizard = new ConfigurePythonWizard(apiWrapper, testInstallation); + // await wizard.start(); + + // should(wizard.wizard.message).be.undefined(); + + // let testMsg = 'Test message'; + // wizard.showErrorMessage(testMsg); + // should(wizard.wizard.message.text).be.equal(testMsg); + // should(wizard.wizard.message.level).be.equal(azdata.window.MessageLevel.Error); + + // wizard.clearStatusMessage(); + // should(wizard.wizard.message).be.undefined(); + + // await wizard.close(); + // }); + + it('Configure Path Page test', async () => { + let testPythonLocation = '/not/a/real/path'; + let model = { + useExistingPython: true, + pythonPathsPromise: Promise.resolve([{ + installDir: testPythonLocation, + version: '4000' + }]) + }; + + let page = azdata.window.createWizardPage('Page 1'); + let configurePathPage = new ConfigurePathPage(apiWrapper, testWizard, page, model, viewContext.view); + + should(await configurePathPage.initialize()).be.true(); + + // First page, so onPageEnter should do nothing + await should(configurePathPage.onPageEnter()).be.resolved(); + + should(await configurePathPage.onPageLeave()).be.true(); + should(model.useExistingPython).be.true(); + should(model.pythonLocation).be.equal(testPythonLocation); + }); + + it('Pick Packages Page test', async () => { + let model = { + kernelName: allKernelsName, + installation: testInstallation, + pythonLocation: '/not/a/real/path', + useExistingPython: true + }; + + let page = azdata.window.createWizardPage('Page 2'); + let pickPackagesPage = new PickPackagesPage(apiWrapper, testWizard, page, model, viewContext.view); + + should(await pickPackagesPage.initialize()).be.true(); + + should((pickPackagesPage).kernelLabel).not.be.undefined(); + should((pickPackagesPage).kernelDropdown).be.undefined(); + + // Last page, so onPageLeave should do nothing + should(await pickPackagesPage.onPageLeave()).be.true(); + + await should(pickPackagesPage.onPageEnter()).be.resolved(); + should(model.packagesToInstall).be.deepEqual(JupyterServerInstallation.getRequiredPackagesForKernel(allKernelsName)); + }); + + it('Undefined kernel test', async () => { + let model = { + kernelName: undefined, + installation: testInstallation, + pythonLocation: '/not/a/real/path', + useExistingPython: true + }; + + let page = azdata.window.createWizardPage('Page 2'); + let pickPackagesPage = new PickPackagesPage(apiWrapper, testWizard, page, model, viewContext.view); + + should(await pickPackagesPage.initialize()).be.true(); + + should((pickPackagesPage).kernelLabel).be.undefined(); + should((pickPackagesPage).kernelDropdown).not.be.undefined(); + + await should(pickPackagesPage.onPageEnter()).be.resolved(); + should(model.packagesToInstall).be.deepEqual(JupyterServerInstallation.getRequiredPackagesForKernel(python3DisplayName)); + }); +}); diff --git a/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts b/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts index 6ee2448ff3..dec65c6b1e 100644 --- a/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts +++ b/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts @@ -8,7 +8,7 @@ import * as azdata from 'azdata'; import * as should from 'should'; import 'mocha'; import * as TypeMoq from 'typemoq'; -import { JupyterServerInstallation, PythonPkgDetails, IJupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; +import { JupyterServerInstallation, PythonPkgDetails, IJupyterServerInstallation, PythonInstallSettings } from '../../jupyter/jupyterServerInstallation'; import { LocalCondaPackageManageProvider } from '../../jupyter/localCondaPackageManageProvider'; import * as constants from '../../common/constants'; import { LocalPipPackageManageProvider } from '../../jupyter/localPipPackageManageProvider'; @@ -188,7 +188,7 @@ describe('Manage Package Providers', () => { serverInstallation: { installCondaPackages: (packages: PythonPkgDetails[], useMinVersion: boolean) => { return Promise.resolve(); }, configurePackagePaths: () => { return Promise.resolve(); }, - startInstallProcess: (forceInstall: boolean, installSettings?: { installPath: string, existingPython: boolean }) => { return Promise.resolve(); }, + startInstallProcess: (forceInstall: boolean, installSettings?: PythonInstallSettings) => { return Promise.resolve(); }, getInstalledPipPackages: () => { return Promise.resolve([]); }, installPipPackages: (packages: PythonPkgDetails[], useMinVersion: boolean) => { return Promise.resolve(); }, uninstallPipPackages: (packages: PythonPkgDetails[]) => { return Promise.resolve(); }, diff --git a/extensions/notebook/src/test/model/serverManager.test.ts b/extensions/notebook/src/test/model/serverManager.test.ts index f72a7856a4..fe4971ba20 100644 --- a/extensions/notebook/src/test/model/serverManager.test.ts +++ b/extensions/notebook/src/test/model/serverManager.test.ts @@ -6,6 +6,7 @@ import * as should from 'should'; import * as TypeMoq from 'typemoq'; import * as vscode from 'vscode'; +import * as azdata from 'azdata'; import 'mocha'; import { JupyterServerInstanceStub } from '../common'; @@ -18,6 +19,10 @@ import { IServerInstance } from '../../jupyter/common'; import { MockExtensionContext } from '../common/stubs'; describe('Local Jupyter Server Manager', function (): void { + const pythonKernelSpec: azdata.nb.IKernelSpec = { + name: 'python3', + display_name: 'Python 3' + }; let expectedPath = 'my/notebook.ipynb'; let serverManager: LocalJupyterServerManager; let deferredInstall: Deferred; @@ -33,7 +38,7 @@ describe('Local Jupyter Server Manager', function (): void { deferredInstall = new Deferred(); let mockInstall = TypeMoq.Mock.ofType(JupyterServerInstallation, undefined, undefined, '/root'); - mockInstall.setup(j => j.promptForPythonInstall()).returns(() => deferredInstall.promise); + mockInstall.setup(j => j.promptForPythonInstall(TypeMoq.It.isAny())).returns(() => deferredInstall.promise); mockInstall.object.execOptions = { env: Object.assign({}, process.env) }; serverManager = new LocalJupyterServerManager({ @@ -53,7 +58,7 @@ describe('Local Jupyter Server Manager', function (): void { it('Should show error message on install failure', async function (): Promise { let error = 'Error!!'; deferredInstall.reject(error); - await testUtils.assertThrowsAsync(() => serverManager.startServer(), undefined); + await testUtils.assertThrowsAsync(() => serverManager.startServer(pythonKernelSpec), undefined); }); it('Should configure and start install', async function (): Promise { @@ -65,7 +70,7 @@ describe('Local Jupyter Server Manager', function (): void { // When I start the server let notified = false; serverManager.onServerStarted(() => notified = true); - await serverManager.startServer(); + await serverManager.startServer(pythonKernelSpec); // Then I expect the port to be included in settings should(serverManager.serverSettings.baseUrl.indexOf('1234') > -1).be.true(); @@ -89,7 +94,7 @@ describe('Local Jupyter Server Manager', function (): void { deferredInstall.resolve(); // When I start and then the server - await serverManager.startServer(); + await serverManager.startServer(pythonKernelSpec); await serverManager.stopServer(); // Then I expect stop to have been called on the server instance @@ -104,7 +109,7 @@ describe('Local Jupyter Server Manager', function (): void { deferredInstall.resolve(); // When I start and then dispose the extension - await serverManager.startServer(); + await serverManager.startServer(pythonKernelSpec); should(mockExtensionContext.subscriptions).have.length(1); mockExtensionContext.subscriptions[0].dispose(); diff --git a/extensions/notebook/src/test/model/sessionManager.test.ts b/extensions/notebook/src/test/model/sessionManager.test.ts index f581601323..c6bea1deba 100644 --- a/extensions/notebook/src/test/model/sessionManager.test.ts +++ b/extensions/notebook/src/test/model/sessionManager.test.ts @@ -108,7 +108,7 @@ describe('Jupyter Session', function (): void { beforeEach(() => { mockJupyterSession = TypeMoq.Mock.ofType(SessionStub); - session = new JupyterSession(mockJupyterSession.object); + session = new JupyterSession(mockJupyterSession.object, undefined); }); it('should always be able to change kernels', function (): void { diff --git a/src/sql/azdata.d.ts b/src/sql/azdata.d.ts index 4995fa99fc..966b423cff 100644 --- a/src/sql/azdata.d.ts +++ b/src/sql/azdata.d.ts @@ -4590,7 +4590,7 @@ declare module 'azdata' { * Starts the server. Some server types may not support or require this. * Should no-op if server is already started */ - startServer(): Thenable; + startServer(kernelSpec: IKernelSpec): Thenable; /** * Stops the server. Some server types may not support or require this diff --git a/src/sql/workbench/api/browser/mainThreadNotebook.ts b/src/sql/workbench/api/browser/mainThreadNotebook.ts index 9aa363cafb..37c2ca305a 100644 --- a/src/sql/workbench/api/browser/mainThreadNotebook.ts +++ b/src/sql/workbench/api/browser/mainThreadNotebook.ts @@ -184,12 +184,12 @@ class ServerManagerWrapper implements azdata.nb.ServerManager { return this.onServerStartedEmitter.event; } - startServer(): Thenable { - return this.doStartServer(); + startServer(kernelSpec: azdata.nb.IKernelSpec): Thenable { + return this.doStartServer(kernelSpec); } - private async doStartServer(): Promise { - await this._proxy.ext.$doStartServer(this.handle); + private async doStartServer(kernelSpec: azdata.nb.IKernelSpec): Promise { + await this._proxy.ext.$doStartServer(this.handle, kernelSpec); this._isStarted = true; this.onServerStartedEmitter.fire(); } diff --git a/src/sql/workbench/api/common/extHostNotebook.ts b/src/sql/workbench/api/common/extHostNotebook.ts index c6263f526a..8380a9d054 100644 --- a/src/sql/workbench/api/common/extHostNotebook.ts +++ b/src/sql/workbench/api/common/extHostNotebook.ts @@ -54,8 +54,8 @@ export class ExtHostNotebook implements ExtHostNotebookShape { } } - $doStartServer(managerHandle: number): Thenable { - return this._withServerManager(managerHandle, (serverManager) => serverManager.startServer()); + $doStartServer(managerHandle: number, kernelSpec: azdata.nb.IKernelSpec): Thenable { + return this._withServerManager(managerHandle, (serverManager) => serverManager.startServer(kernelSpec)); } $doStopServer(managerHandle: number): Thenable { diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index f5b03c8a12..2253753760 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -806,7 +806,7 @@ export interface ExtHostNotebookShape { $handleNotebookClosed(notebookUri: UriComponents): void; // Server Manager APIs - $doStartServer(managerHandle: number): Thenable; + $doStartServer(managerHandle: number, kernelSpec: azdata.nb.IKernelSpec): Thenable; $doStopServer(managerHandle: number): Thenable; // Content Manager APIs diff --git a/src/sql/workbench/contrib/notebook/test/stubs.ts b/src/sql/workbench/contrib/notebook/test/stubs.ts index 0cc0f3f7a5..15f5ff26bb 100644 --- a/src/sql/workbench/contrib/notebook/test/stubs.ts +++ b/src/sql/workbench/contrib/notebook/test/stubs.ts @@ -178,7 +178,7 @@ export class ServerManagerStub implements nb.ServerManager { calledEnd: boolean = false; result: Promise = undefined; - startServer(): Promise { + startServer(kernelSpec: nb.IKernelSpec): Promise { this.calledStart = true; return this.result; } diff --git a/src/sql/workbench/services/notebook/browser/models/clientSession.ts b/src/sql/workbench/services/notebook/browser/models/clientSession.ts index 47de63b190..80679563a3 100644 --- a/src/sql/workbench/services/notebook/browser/models/clientSession.ts +++ b/src/sql/workbench/services/notebook/browser/models/clientSession.ts @@ -60,7 +60,7 @@ export class ClientSession implements IClientSession { public async initialize(): Promise { try { - this._serverLoadFinished = this.startServer(); + this._serverLoadFinished = this.startServer(this.options.kernelSpec); await this._serverLoadFinished; await this.initializeSession(); await this.updateCachedKernelSpec(); @@ -75,10 +75,10 @@ export class ClientSession implements IClientSession { } } - private async startServer(): Promise { + private async startServer(kernelSpec: nb.IKernelSpec): Promise { let serverManager = this.notebookManager.serverManager; if (serverManager) { - await serverManager.startServer(); + await serverManager.startServer(kernelSpec); if (!serverManager.isStarted) { throw new Error(localize('ServerNotStarted', "Server did not start for unknown reason")); }