diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 9ab65cb6bd..5d12f4530c 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -5,6 +5,10 @@ 'use strict'; +import * as nls from 'vscode-nls'; + +const localize = nls.loadMessageBundle(); + // CONFIG VALUES /////////////////////////////////////////////////////////// export const extensionOutputChannel = 'Notebooks'; @@ -27,6 +31,9 @@ export const jupyterReinstallDependenciesCommand = 'jupyter.reinstallDependencie export const jupyterAnalyzeCommand = 'jupyter.cmd.analyzeNotebook'; export const jupyterManagePackages = 'jupyter.cmd.managePackages'; export const jupyterConfigurePython = 'jupyter.cmd.configurePython'; +export const localhostName = 'localhost'; +export const localhostTitle = localize('managePackages.localhost', "localhost"); +export const PackageNotFoundError = localize('managePackages.packageNotFound', "Could not find the specified package"); export enum BuiltInCommands { SetContext = 'setContext' diff --git a/extensions/notebook/src/dialog/managePackages/addNewPackageTab.ts b/extensions/notebook/src/dialog/managePackages/addNewPackageTab.ts index 673052e88a..d17435e678 100644 --- a/extensions/notebook/src/dialog/managePackages/addNewPackageTab.ts +++ b/extensions/notebook/src/dialog/managePackages/addNewPackageTab.ts @@ -5,12 +5,10 @@ import * as nls from 'vscode-nls'; import * as azdata from 'azdata'; -import * as request from 'request'; import { JupyterServerInstallation, PipPackageOverview } from '../../jupyter/jupyterServerInstallation'; import * as utils from '../../common/utils'; import { ManagePackagesDialog } from './managePackagesDialog'; -import { PythonPkgType } from '../../common/constants'; const localize = nls.loadMessageBundle(); @@ -28,7 +26,6 @@ export class AddNewPackageTab { private packageInstallButton: azdata.ButtonComponent; private readonly InvalidTextPlaceholder = localize('managePackages.invalidTextPlaceholder', "N/A"); - private readonly PackageNotFoundError = localize('managePackages.packageNotFound', "Could not find the specified package"); private readonly SearchPlaceholder = (pkgType: string) => localize('managePackages.searchBarPlaceholder', "Search {0} packages", pkgType); constructor(private dialog: ManagePackagesDialog, private jupyterInstallation: JupyterServerInstallation) { @@ -42,8 +39,8 @@ export class AddNewPackageTab { label: localize('managePackages.searchButtonLabel', "Search"), width: '200px' }).component(); - this.packagesSearchButton.onDidClick(() => { - this.loadNewPackageInfo(); + this.packagesSearchButton.onDidClick(async () => { + await this.loadNewPackageInfo(); }); this.newPackagesName = view.modelBuilder.text().withProperties({ width: '400px' }).component(); @@ -65,8 +62,8 @@ export class AddNewPackageTab { label: localize('managePackages.installButtonText', "Install"), width: '200px' }).component(); - this.packageInstallButton.onDidClick(() => { - this.doPackageInstall(); + this.packageInstallButton.onDidClick(async () => { + await this.doPackageInstall(); }); let formModel = view.modelBuilder.formContainer() @@ -107,7 +104,7 @@ export class AddNewPackageTab { await this.newPackagesSearchBar.updateProperties({ value: '', - placeHolder: this.SearchPlaceholder(this.dialog.currentPkgType) + placeHolder: this.SearchPlaceholder(this.dialog.model.currentPackageType) }); await this.setFieldsToEmpty(); } finally { @@ -145,11 +142,7 @@ export class AddNewPackageTab { } let pipPackage: PipPackageOverview; - if (this.dialog.currentPkgType === PythonPkgType.Anaconda) { - pipPackage = await this.fetchCondaPackage(packageName); - } else { - pipPackage = await this.fetchPypiPackage(packageName); - } + pipPackage = await this.dialog.model.getPackageOverview(packageName); if (!pipPackage.versions || pipPackage.versions.length === 0) { this.dialog.showErrorMessage( localize('managePackages.noVersionsFound', @@ -179,86 +172,7 @@ export class AddNewPackageTab { } } - private async fetchPypiPackage(packageName: string): Promise { - return new Promise((resolve, reject) => { - request.get(`https://pypi.org/pypi/${packageName}/json`, { timeout: 10000 }, (error, response, body) => { - if (error) { - return reject(error); - } - if (response.statusCode === 404) { - return reject(this.PackageNotFoundError); - } - - if (response.statusCode !== 200) { - return reject( - localize('managePackages.packageRequestError', - "Package info request failed with error: {0} {1}", - response.statusCode, - response.statusMessage)); - } - - let versionNums: string[] = []; - let packageSummary = ''; - - let packagesJson = JSON.parse(body); - if (packagesJson) { - if (packagesJson.releases) { - let versionKeys = Object.keys(packagesJson.releases); - versionKeys = versionKeys.filter(versionKey => { - let releaseInfo = packagesJson.releases[versionKey]; - return Array.isArray(releaseInfo) && releaseInfo.length > 0; - }); - versionNums = utils.sortPackageVersions(versionKeys, false); - } - - if (packagesJson.info && packagesJson.info.summary) { - packageSummary = packagesJson.info.summary; - } - } - - resolve({ - name: packageName, - versions: versionNums, - summary: packageSummary - }); - }); - }); - } - - private async fetchCondaPackage(packageName: string): Promise { - let condaExe = this.jupyterInstallation.getCondaExePath(); - let cmd = `"${condaExe}" search --json ${packageName}`; - let packageResult: string; - try { - packageResult = await this.jupyterInstallation.executeBufferedCommand(cmd); - } catch (err) { - throw new Error(this.PackageNotFoundError); - } - - if (packageResult) { - let packageJson = JSON.parse(packageResult); - if (packageJson) { - if (packageJson.error) { - throw new Error(packageJson.error); - } - - let packages = packageJson[packageName]; - if (Array.isArray(packages)) { - let allVersions = packages.filter(pkg => pkg && pkg.version).map(pkg => pkg.version); - let singletonVersions = new Set(allVersions); - let sortedVersions = utils.sortPackageVersions(Array.from(singletonVersions), false); - return { - name: packageName, - versions: sortedVersions, - summary: undefined - }; - } - } - } - - return undefined; - } private async doPackageInstall(): Promise { let packageName = this.newPackagesName.value; @@ -278,11 +192,7 @@ export class AddNewPackageTab { isCancelable: false, operation: op => { let installPromise: Promise; - if (this.dialog.currentPkgType === PythonPkgType.Anaconda) { - installPromise = this.jupyterInstallation.installCondaPackages([{ name: packageName, version: packageVersion }], false); - } else { - installPromise = this.jupyterInstallation.installPipPackages([{ name: packageName, version: packageVersion }], false); - } + installPromise = this.dialog.model.installPackages([{ name: packageName, version: packageVersion }]); installPromise .then(async () => { let installMsg = localize('managePackages.backgroundInstallComplete', diff --git a/extensions/notebook/src/dialog/managePackages/installedPackagesTab.ts b/extensions/notebook/src/dialog/managePackages/installedPackagesTab.ts index ba6f624bfb..7279863c7c 100644 --- a/extensions/notebook/src/dialog/managePackages/installedPackagesTab.ts +++ b/extensions/notebook/src/dialog/managePackages/installedPackagesTab.ts @@ -11,7 +11,6 @@ import * as utils from '../../common/utils'; import { ManagePackagesDialog } from './managePackagesDialog'; import CodeAdapter from '../../prompts/adapter'; import { QuestionTypes, IQuestion } from '../../prompts/question'; -import { PythonPkgType } from '../../common/constants'; const localize = nls.loadMessageBundle(); @@ -21,6 +20,7 @@ export class InstalledPackagesTab { private installedPkgTab: azdata.window.DialogTab; private packageTypeDropdown: azdata.DropDownComponent; + private locationComponent: azdata.TextComponent; private installedPackageCount: azdata.TextComponent; private installedPackagesTable: azdata.TableComponent; private installedPackagesLoader: azdata.LoadingComponent; @@ -32,18 +32,28 @@ export class InstalledPackagesTab { this.installedPkgTab = azdata.window.createTab(localize('managePackages.installedTabTitle', "Installed")); this.installedPkgTab.registerContent(async view => { - let dropdownValues: string[]; - if (this.dialog.currentPkgType === PythonPkgType.Anaconda) { - dropdownValues = [PythonPkgType.Anaconda, PythonPkgType.Pip]; - } else { - dropdownValues = [PythonPkgType.Pip]; - } + + // TODO: only supporting single location for now. We should add a drop down for multi locations mode + // + let locationTitle = await this.dialog.model.getLocationTitle(); + this.locationComponent = view.modelBuilder.text().withProperties({ + value: locationTitle + }).component(); + + let dropdownValues = this.dialog.model.getPackageTypes().map(x => { + return { + name: x.providerId, + displayName: x.packageType + }; + }); + let defaultPackageType = this.dialog.model.getDefaultPackageType(); this.packageTypeDropdown = view.modelBuilder.dropDown().withProperties({ values: dropdownValues, - value: dropdownValues[0] + value: defaultPackageType }).component(); + this.dialog.changeProvider(defaultPackageType.providerId); this.packageTypeDropdown.onValueChanged(() => { - this.dialog.resetPages(this.packageTypeDropdown.value as PythonPkgType) + this.dialog.resetPages((this.packageTypeDropdown.value).name) .catch(err => { this.dialog.showErrorMessage(utils.getErrorMessage(err)); }); @@ -73,6 +83,9 @@ export class InstalledPackagesTab { let formModel = view.modelBuilder.formContainer() .withFormItems([{ + component: this.locationComponent, + title: localize('managePackages.location', "Location") + }, { component: this.packageTypeDropdown, title: localize('managePackages.packageType', "Package Type") }, { @@ -95,6 +108,7 @@ export class InstalledPackagesTab { await view.initializeModel(this.installedPackagesLoader); await this.loadInstalledPackagesInfo(); + this.packageTypeDropdown.focus(); }); } @@ -108,11 +122,7 @@ export class InstalledPackagesTab { await this.installedPackagesLoader.updateProperties({ loading: true }); await this.uninstallPackageButton.updateProperties({ enabled: false }); try { - if (this.dialog.currentPkgType === PythonPkgType.Anaconda) { - pythonPackages = await this.jupyterInstallation.getInstalledCondaPackages(); - } else { - pythonPackages = await this.jupyterInstallation.getInstalledPipPackages(); - } + pythonPackages = await this.dialog.model.listPackages(); } catch (err) { this.dialog.showErrorMessage(utils.getErrorMessage(err)); } finally { @@ -131,7 +141,7 @@ export class InstalledPackagesTab { await this.installedPackageCount.updateProperties({ value: localize('managePackages.packageCount', "{0} {1} packages found", packageCount, - this.dialog.currentPkgType) + this.dialog.model.currentPackageType) }); if (packageData && packageData.length > 0) { @@ -178,12 +188,7 @@ export class InstalledPackagesTab { description: taskName, isCancelable: false, operation: op => { - let uninstallPromise: Promise; - if (this.dialog.currentPkgType === PythonPkgType.Anaconda) { - uninstallPromise = this.jupyterInstallation.uninstallCondaPackages(packages); - } else { - uninstallPromise = this.jupyterInstallation.uninstallPipPackages(packages); - } + let uninstallPromise: Promise = this.dialog.model.uninstallPackages(packages); uninstallPromise .then(async () => { let uninstallMsg = localize('managePackages.backgroundUninstallComplete', @@ -213,4 +218,4 @@ export class InstalledPackagesTab { this.uninstallPackageButton.updateProperties({ enabled: true }); } -} \ No newline at end of file +} diff --git a/extensions/notebook/src/dialog/managePackages/managePackagesDialog.ts b/extensions/notebook/src/dialog/managePackages/managePackagesDialog.ts index 6560094753..644888c61d 100644 --- a/extensions/notebook/src/dialog/managePackages/managePackagesDialog.ts +++ b/extensions/notebook/src/dialog/managePackages/managePackagesDialog.ts @@ -9,7 +9,7 @@ import * as azdata from 'azdata'; import { JupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; import { InstalledPackagesTab } from './installedPackagesTab'; import { AddNewPackageTab } from './addNewPackageTab'; -import { PythonPkgType } from '../../common/constants'; +import { ManagePackagesDialogModel } from './managePackagesDialogModel'; const localize = nls.loadMessageBundle(); @@ -18,10 +18,8 @@ export class ManagePackagesDialog { private installedPkgTab: InstalledPackagesTab; private addNewPkgTab: AddNewPackageTab; - public currentPkgType: PythonPkgType; - - constructor(private jupyterInstallation: JupyterServerInstallation) { - this.currentPkgType = this.jupyterInstallation.usingConda ? PythonPkgType.Anaconda : PythonPkgType.Pip; + constructor( + private _managePackageDialogModel: ManagePackagesDialogModel) { } /** @@ -49,8 +47,37 @@ export class ManagePackagesDialog { return this.installedPkgTab.loadInstalledPackagesInfo(); } - public async resetPages(newPkgType: PythonPkgType): Promise { - this.currentPkgType = newPkgType; + public get jupyterInstallation(): JupyterServerInstallation { + return this._managePackageDialogModel.jupyterInstallation; + } + + /** + * Dialog model instance + */ + public get model(): ManagePackagesDialogModel { + return this._managePackageDialogModel; + } + + /** + * Changes the current provider id + * @param providerId Provider Id + */ + public changeProvider(providerId: string): void { + this.model.changeProvider(providerId); + } + + /** + * Resets the tabs for given provider Id + * @param providerId Package Management Provider Id + */ + public async resetPages(providerId: string): Promise { + + // Change the provider in the model + // + this.changeProvider(providerId); + + // Load packages for given provider + // await this.installedPkgTab.loadInstalledPackagesInfo(); await this.addNewPkgTab.resetPageFields(); } diff --git a/extensions/notebook/src/dialog/managePackages/managePackagesDialogModel.ts b/extensions/notebook/src/dialog/managePackages/managePackagesDialogModel.ts new file mode 100644 index 0000000000..66bee24037 --- /dev/null +++ b/extensions/notebook/src/dialog/managePackages/managePackagesDialogModel.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { JupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; +import { IPackageManageProvider, IPackageDetails, IPackageOverview } from '../../types'; + +export interface ManagePackageDialogOptions { + multiLocations: boolean; + defaultLocation?: string; + defaultProviderId?: string; +} + +export interface ProviderPackageType { + packageType: string; + providerId: string; +} + +/** + * Manage package dialog model + */ +export class ManagePackagesDialogModel { + + private _currentProvider: string; + + /** + * A set for locations + */ + private _locations: Set = new Set(); + + /** + * Map of locations to providers + */ + private _packageTypes: Map = new Map(); + + /** + * Creates new instance of the model + * @param _jupyterInstallation Jupyter installation + * @param _packageManageProviders package manage providers + * @param _options dialog options + */ + constructor( + private _jupyterInstallation: JupyterServerInstallation, + private _packageManageProviders: Map, + private _options?: ManagePackageDialogOptions) { + + if (!this._packageManageProviders || this._packageManageProviders.size === 0) { + throw Error('Invalid list of package manager providers'); + } + } + + /** + * Initialized the model + */ + public async init(): Promise { + await this.loadCaches(); + this.loadOptions(); + this.changeProvider(this.defaultProviderId); + } + + /** + * Loads the model options + */ + private loadOptions(): void { + + // Set Default Options + // + if (!this._options) { + this._options = this.defaultOptions; + } + + if (this._options.defaultLocation && !this._packageTypes.has(this._options.defaultLocation)) { + throw new Error(`Invalid default location '${this._options.defaultLocation}`); + } + + if (this._options.defaultProviderId && !this._packageManageProviders.has(this._options.defaultProviderId)) { + throw new Error(`Invalid default provider id '${this._options.defaultProviderId}`); + } + + if (!this._options.multiLocations && !this.defaultLocation) { + throw new Error('Default location not specified for single location mode'); + } + } + + private get defaultOptions(): ManagePackageDialogOptions { + return { + multiLocations: true, + defaultLocation: undefined, + defaultProviderId: undefined + }; + } + + /** + * Returns the providers map + */ + public get packageManageProviders(): Map { + return this._packageManageProviders; + } + + /** + * Returns the current provider + */ + public get currentPackageManageProvider(): IPackageManageProvider | undefined { + if (this._currentProvider) { + let provider = this._packageManageProviders.get(this._currentProvider); + return provider; + } + return undefined; + } + + /** + * Returns the current provider + */ + public get currentPackageType(): string | undefined { + if (this._currentProvider) { + let provider = this._packageManageProviders.get(this._currentProvider); + return provider.packageTarget.packageType; + } + return undefined; + } + + /** + * Returns true if multi locations mode is enabled + */ + public get multiLocationMode(): boolean { + return this.options.multiLocations; + } + + /** + * Returns options + */ + public get options(): ManagePackageDialogOptions { + return this._options || this.defaultOptions; + } + + /** + * returns the array of target locations + */ + public get targetLocations(): string[] { + return Array.from(this._locations.keys()); + } + + /** + * Returns the default location + */ + public get defaultLocation(): string { + return this.options.defaultLocation || this.targetLocations[0]; + } + + /** + * Returns the default location + */ + public get defaultProviderId(): string { + return this.options.defaultProviderId || Array.from(this.packageManageProviders.keys())[0]; + } + + /** + * Loads the provider cache + */ + private async loadCaches(): Promise { + if (this.packageManageProviders) { + let keyArray = Array.from(this.packageManageProviders.keys()); + for (let index = 0; index < keyArray.length; index++) { + const element = this.packageManageProviders.get(keyArray[index]); + if (await element.canUseProvider()) { + if (!this._locations.has(element.packageTarget.location)) { + this._locations.add(element.packageTarget.location); + } + if (!this._packageTypes.has(element.packageTarget.location)) { + this._packageTypes.set(element.packageTarget.location, []); + } + this._packageTypes.get(element.packageTarget.location).push(element); + } + } + } + } + + /** + * Returns a map of providerId to package types for given location + */ + public getPackageTypes(targetLocation?: string): ProviderPackageType[] { + targetLocation = targetLocation || this.defaultLocation; + let providers = this._packageTypes.get(targetLocation); + return providers.map(x => { + return { + providerId: x.providerId, + packageType: x.packageTarget.packageType + }; + }); + } + + /** + * Returns a map of providerId to package types for given location + */ + public getDefaultPackageType(): ProviderPackageType { + let defaultProviderId = this.defaultProviderId; + let packageTypes = this.getPackageTypes(); + return packageTypes.find(x => x.providerId === defaultProviderId); + } + + /** + * returns the list of packages for current provider + */ + public async listPackages(): Promise { + let provider = this.currentPackageManageProvider; + if (provider) { + return await provider.listPackages(); + } else { + throw new Error('Current Provider is not set'); + } + } + + /** + * Changes the current provider + */ + public changeProvider(providerId: string): void { + if (this._packageManageProviders.has(providerId)) { + this._currentProvider = providerId; + } else { + throw Error(`Invalid package type ${providerId}`); + } + } + + /** + * Installs given packages using current provider + * @param packages Packages to install + */ + public async installPackages(packages: IPackageDetails[]): Promise { + let provider = this.currentPackageManageProvider; + if (provider) { + await provider.installPackages(packages, false); + } else { + throw new Error('Current Provider is not set'); + } + } + + /** + * Returns the location title for current provider + */ + public async getLocationTitle(): Promise { + let provider = this.currentPackageManageProvider; + if (provider) { + return await provider.getLocationTitle(); + } + return Promise.resolve(undefined); + } + + /** + * UnInstalls given packages using current provider + * @param packages Packages to install + */ + public async uninstallPackages(packages: IPackageDetails[]): Promise { + let provider = this.currentPackageManageProvider; + if (provider) { + await provider.uninstallPackages(packages); + } else { + throw new Error('Current Provider is not set'); + } + } + + /** + * Returns package preview for given name + * @param packageName Package name + */ + public async getPackageOverview(packageName: string): Promise { + let provider = this.currentPackageManageProvider; + if (provider) { + return await provider.getPackageOverview(packageName); + } else { + throw new Error('Current Provider is not set'); + } + } + + /** + * Returns the jupyterInstallation instance + */ + public get jupyterInstallation(): JupyterServerInstallation { + return this._jupyterInstallation; + } +} diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index bdbfba62a1..98ae93cbc5 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -11,7 +11,7 @@ import * as nls from 'vscode-nls'; import { JupyterController } from './jupyter/jupyterController'; import { AppContext } from './common/appContext'; import { ApiWrapper } from './common/apiWrapper'; -import { IExtensionApi } from './types'; +import { IExtensionApi, IPackageManageProvider } from './types'; import { CellType } from './contracts/content'; import { getErrorMessage, isEditorTitleFree } from './common/utils'; import { NotebookUriHandler } from './protocol/notebookUriHandler'; @@ -115,6 +115,12 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return { getJupyterController() { return controller; + }, + registerPackageManager(providerId: string, packageManagerProvider: IPackageManageProvider): void { + controller.registerPackageManager(providerId, packageManagerProvider); + }, + getPackageManagers() { + return controller.packageManageProviders; } }; } diff --git a/extensions/notebook/src/jupyter/jupyterController.ts b/extensions/notebook/src/jupyter/jupyterController.ts index e54ad378dd..3921185430 100644 --- a/extensions/notebook/src/jupyter/jupyterController.ts +++ b/extensions/notebook/src/jupyter/jupyterController.ts @@ -25,6 +25,11 @@ import { JupyterNotebookProvider } from './jupyterNotebookProvider'; import { ConfigurePythonDialog } from '../dialog/configurePythonDialog'; import CodeAdapter from '../prompts/adapter'; import { ManagePackagesDialog } from '../dialog/managePackages/managePackagesDialog'; +import { IPackageManageProvider } from '../types'; +import { LocalPipPackageManageProvider } from './localPipPackageManageProvider'; +import { LocalCondaPackageManageProvider } from './localCondaPackageManageProvider'; +import { ManagePackagesDialogModel, ManagePackageDialogOptions } from '../dialog/managePackages/managePackagesDialogModel'; +import { PiPyClient } from './pipyClient'; let untitledCounter = 0; @@ -32,6 +37,7 @@ export class JupyterController implements vscode.Disposable { private _jupyterInstallation: JupyterServerInstallation; private _notebookInstances: IServerInstance[] = []; private _serverInstanceFactory: ServerInstanceFactory = new ServerInstanceFactory(); + private _packageManageProviders = new Map(); private outputChannel: vscode.OutputChannel; private prompter: IPrompter; @@ -76,7 +82,7 @@ export class JupyterController implements vscode.Disposable { }); this.apiWrapper.registerCommand(constants.jupyterReinstallDependenciesCommand, () => { return this.handleDependenciesReinstallation(); }); - this.apiWrapper.registerCommand(constants.jupyterManagePackages, () => { return this.doManagePackages(); }); + this.apiWrapper.registerCommand(constants.jupyterManagePackages, async (args) => { return this.doManagePackages(args); }); this.apiWrapper.registerCommand(constants.jupyterConfigurePython, () => { return this.doConfigurePython(this._jupyterInstallation); }); let supportedFileFilter: vscode.DocumentFilter[] = [ @@ -85,6 +91,7 @@ export class JupyterController implements vscode.Disposable { let notebookProvider = this.registerNotebookProvider(); this.extensionContext.subscriptions.push(this.apiWrapper.registerCompletionItemProvider(supportedFileFilter, new NotebookCompletionItemProvider(notebookProvider))); + this.registerDefaultPackageManageProviders(); return true; } @@ -110,7 +117,7 @@ export class JupyterController implements vscode.Disposable { public deactivate(): void { // Shutdown any open notebooks - this._notebookInstances.forEach(instance => { instance.stop(); }); + this._notebookInstances.forEach(async (instance) => { await instance.stop(); }); } // EVENT HANDLERS ////////////////////////////////////////////////////// @@ -196,9 +203,19 @@ export class JupyterController implements vscode.Disposable { }); } - public doManagePackages(): void { + public async doManagePackages(options?: ManagePackageDialogOptions): Promise { try { - let packagesDialog = new ManagePackagesDialog(this._jupyterInstallation); + if (!options) { + options = { + multiLocations: false, + defaultLocation: constants.localhostName, + defaultProviderId: LocalPipPackageManageProvider.ProviderId + }; + } + let model = new ManagePackagesDialogModel(this._jupyterInstallation, this._packageManageProviders, options); + + await model.init(); + let packagesDialog = new ManagePackagesDialog(model); packagesDialog.showDialog(); } catch (error) { let message = utils.getErrorMessage(error); @@ -206,6 +223,33 @@ export class JupyterController implements vscode.Disposable { } } + /** + * Register a package provider + * @param providerId Provider Id + * @param packageManageProvider Provider instance + */ + public registerPackageManager(providerId: string, packageManageProvider: IPackageManageProvider): void { + if (packageManageProvider) { + if (!this._packageManageProviders.has(providerId)) { + this._packageManageProviders.set(providerId, packageManageProvider); + } else { + throw Error(`Package manager provider is already registered. provider id: ${providerId}`); + } + } + } + + /** + * Returns the list of registered providers + */ + public get packageManageProviders(): Map { + return this._packageManageProviders; + } + + private registerDefaultPackageManageProviders(): void { + this.registerPackageManager(LocalPipPackageManageProvider.ProviderId, new LocalPipPackageManageProvider(this._jupyterInstallation, new PiPyClient())); + this.registerPackageManager(LocalCondaPackageManageProvider.ProviderId, new LocalCondaPackageManageProvider(this._jupyterInstallation)); + } + public doConfigurePython(jupyterInstaller: JupyterServerInstallation): void { let pythonDialog = new ConfigurePythonDialog(this.apiWrapper, jupyterInstaller); pythonDialog.showDialog().catch((err: any) => { diff --git a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts index 894dcc4ef0..3e799b0358 100644 --- a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts +++ b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts @@ -38,7 +38,22 @@ 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 class JupyterServerInstallation { +export interface IJupyterServerInstallation { + installCondaPackages(packages: PythonPkgDetails[], useMinVersion: boolean): Promise; + configurePackagePaths(): Promise; + startInstallProcess(forceInstall: boolean, installSettings?: { installPath: string, existingPython: boolean }): Promise; + getInstalledPipPackages(): Promise; + getInstalledCondaPackages(): Promise; + uninstallCondaPackages(packages: PythonPkgDetails[]): Promise; + usingConda: boolean; + getCondaExePath(): string; + executeBufferedCommand(command: string): Promise; + executeStreamedCommand(command: string): Promise; + installPipPackages(packages: PythonPkgDetails[], useMinVersion: boolean): Promise; + uninstallPipPackages(packages: PythonPkgDetails[]): Promise; + pythonExecutable: string; +} +export class JupyterServerInstallation implements IJupyterServerInstallation { public apiWrapper: ApiWrapper; public extensionPath: string; public pythonBinPath: string; @@ -625,7 +640,7 @@ export class JupyterServerInstallation { } } - private async executeStreamedCommand(command: string): Promise { + public async executeStreamedCommand(command: string): Promise { await utils.executeStreamedCommand(command, { env: this.execOptions.env }, this.outputChannel); } diff --git a/extensions/notebook/src/jupyter/localCondaPackageManageProvider.ts b/extensions/notebook/src/jupyter/localCondaPackageManageProvider.ts new file mode 100644 index 0000000000..c5f4b60f44 --- /dev/null +++ b/extensions/notebook/src/jupyter/localCondaPackageManageProvider.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPackageManageProvider, IPackageDetails, IPackageTarget, IPackageOverview } from '../types'; +import { IJupyterServerInstallation } from './jupyterServerInstallation'; +import * as constants from '../common/constants'; +import * as utils from '../common/utils'; + +export class LocalCondaPackageManageProvider implements IPackageManageProvider { + + /** + * Provider Id for Anaconda package manage provider + */ + public static ProviderId = 'localhost_Anaconda'; + + constructor(private jupyterInstallation: IJupyterServerInstallation) { + } + + /** + * Returns package target + */ + public get packageTarget(): IPackageTarget { + return { location: constants.localhostName, packageType: constants.PythonPkgType.Anaconda }; + } + + /** + * Returns provider Id + */ + public get providerId(): string { + return LocalCondaPackageManageProvider.ProviderId; + } + + /** + * Returns list of packages + */ + public async listPackages(): Promise { + return await this.jupyterInstallation.getInstalledCondaPackages(); + } + + /** + * Installs given packages + * @param packages Packages to install + * @param useMinVersion minimum version + */ + installPackages(packages: IPackageDetails[], useMinVersion: boolean): Promise { + return this.jupyterInstallation.installCondaPackages(packages, useMinVersion); + } + + /** + * Uninstalls given packages + * @param packages Packages to uninstall + */ + uninstallPackages(packages: IPackageDetails[]): Promise { + return this.jupyterInstallation.uninstallCondaPackages(packages); + } + + /** + * Returns true if the provider can be used + */ + canUseProvider(): Promise { + return Promise.resolve(this.jupyterInstallation.usingConda); + } + + /** + * Returns location title + */ + getLocationTitle(): Promise { + return Promise.resolve(constants.localhostTitle); + } + + /** + * Returns package overview for given name + * @param packageName Package Name + */ + getPackageOverview(packageName: string): Promise { + return this.fetchCondaPackage(packageName); + } + + private async fetchCondaPackage(packageName: string): Promise { + let condaExe = this.jupyterInstallation.getCondaExePath(); + let cmd = `"${condaExe}" search --json ${packageName}`; + let packageResult: string; + try { + packageResult = await this.jupyterInstallation.executeBufferedCommand(cmd); + } catch (err) { + throw new Error(constants.PackageNotFoundError); + } + + if (packageResult) { + let packageJson = JSON.parse(packageResult); + if (packageJson) { + if (packageJson.error) { + throw new Error(packageJson.error); + } + + let packages = packageJson[packageName]; + if (Array.isArray(packages)) { + let allVersions = packages.filter(pkg => pkg && pkg.version).map(pkg => pkg.version); + let singletonVersions = new Set(allVersions); + let sortedVersions = utils.sortPackageVersions(Array.from(singletonVersions), false); + return { + name: packageName, + versions: sortedVersions, + summary: undefined + }; + } + } + } + + return undefined; + } +} diff --git a/extensions/notebook/src/jupyter/localPipPackageManageProvider.ts b/extensions/notebook/src/jupyter/localPipPackageManageProvider.ts new file mode 100644 index 0000000000..1be49dcc78 --- /dev/null +++ b/extensions/notebook/src/jupyter/localPipPackageManageProvider.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPackageManageProvider, IPackageDetails, IPackageTarget, IPackageOverview } from '../types'; +import { IJupyterServerInstallation } from './jupyterServerInstallation'; +import * as constants from '../common/constants'; +import * as utils from '../common/utils'; +import { IPiPyClient } from './pipyClient'; + +export class LocalPipPackageManageProvider implements IPackageManageProvider { + + /** + * Provider Id for Pip package manage provider + */ + public static ProviderId = 'localhost_Pip'; + + constructor( + private jupyterInstallation: IJupyterServerInstallation, + private pipyClient: IPiPyClient) { + } + + /** + * Returns provider Id + */ + public get providerId(): string { + return LocalPipPackageManageProvider.ProviderId; + } + + /** + * Returns package target + */ + public get packageTarget(): IPackageTarget { + return { location: constants.localhostName, packageType: constants.PythonPkgType.Pip }; + } + + /** + * Returns list of packages + */ + public async listPackages(): Promise { + return await this.jupyterInstallation.getInstalledPipPackages(); + } + + /** + * Installs given packages + * @param packages Packages to install + * @param useMinVersion minimum version + */ + installPackages(packages: IPackageDetails[], useMinVersion: boolean): Promise { + return this.jupyterInstallation.installPipPackages(packages, useMinVersion); + } + + /** + * Uninstalls given packages + * @param packages Packages to uninstall + */ + uninstallPackages(packages: IPackageDetails[]): Promise { + return this.jupyterInstallation.uninstallPipPackages(packages); + } + + /** + * Returns true if the provider can be used + */ + canUseProvider(): Promise { + return Promise.resolve(true); + } + + /** + * Returns location title + */ + getLocationTitle(): Promise { + return Promise.resolve(constants.localhostTitle); + } + + /** + * Returns package overview for given name + * @param packageName Package Name + */ + getPackageOverview(packageName: string): Promise { + return this.fetchPypiPackage(packageName); + } + + private async fetchPypiPackage(packageName: string): Promise { + let body = await this.pipyClient.fetchPypiPackage(packageName); + let packagesJson = JSON.parse(body); + let versionNums: string[] = []; + let packageSummary = ''; + if (packagesJson) { + if (packagesJson.releases) { + let versionKeys = Object.keys(packagesJson.releases); + versionKeys = versionKeys.filter(versionKey => { + let releaseInfo = packagesJson.releases[versionKey]; + return Array.isArray(releaseInfo) && releaseInfo.length > 0; + }); + versionNums = utils.sortPackageVersions(versionKeys, false); + } + + if (packagesJson.info && packagesJson.info.summary) { + packageSummary = packagesJson.info.summary; + } + } + + return { + name: packageName, + versions: versionNums, + summary: packageSummary + }; + } +} diff --git a/extensions/notebook/src/jupyter/pipyClient.ts b/extensions/notebook/src/jupyter/pipyClient.ts new file mode 100644 index 0000000000..2a82ceb740 --- /dev/null +++ b/extensions/notebook/src/jupyter/pipyClient.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 request from 'request'; +import * as constants from '../common/constants'; + +const localize = nls.loadMessageBundle(); + +export interface IPiPyClient { + fetchPypiPackage(packageName: string): Promise; +} + +export class PiPyClient implements IPiPyClient { + + private readonly RequestTimeout = 10000; + private getLink(packageName: string): string { + return `https://pypi.org/pypi/${packageName}/json`; + } + + public async fetchPypiPackage(packageName: string): Promise { + return new Promise((resolve, reject) => { + request.get(this.getLink(packageName), { timeout: this.RequestTimeout }, (error, response, body) => { + if (error) { + return reject(error); + } + + if (response.statusCode === 404) { + return reject(constants.PackageNotFoundError); + } + + if (response.statusCode !== 200) { + return reject( + localize('managePackages.packageRequestError', + "Package info request failed with error: {0} {1}", + response.statusCode, + response.statusMessage)); + } + + resolve(body); + }); + }); + } +} diff --git a/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts b/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts new file mode 100644 index 0000000000..0a719fb905 --- /dev/null +++ b/extensions/notebook/src/test/managePackages/localPackageManageProvider.test.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import 'mocha'; +import * as TypeMoq from 'typemoq'; +import { JupyterServerInstallation, PythonPkgDetails, IJupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; +import { LocalCondaPackageManageProvider } from '../../jupyter/localCondaPackageManageProvider'; +import * as constants from '../../common/constants'; +import { LocalPipPackageManageProvider } from '../../jupyter/localPipPackageManageProvider'; +import { IPiPyClient, PiPyClient } from '../../jupyter/pipyClient'; + +interface TestContext { + serverInstallation: IJupyterServerInstallation; + piPyClient: IPiPyClient; +} + +describe('Manage Package Providers', () => { + + it('Conda should return valid package target', async function (): Promise { + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + should.deepEqual(provider.packageTarget, { location: constants.localhostName, packageType: constants.PythonPkgType.Anaconda }); + }); + + it('Pip should return valid package target', async function (): Promise { + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + should.deepEqual(provider.packageTarget, { location: constants.localhostName, packageType: constants.PythonPkgType.Pip }); + }); + + it('Pip listPackages should return valid packages', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + testContext.serverInstallation.getInstalledPipPackages = () => { + return Promise.resolve(packages); + }; + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + + should.deepEqual(await provider.listPackages(), packages); + }); + + it('Conda listPackages should return valid packages', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + testContext.serverInstallation.getInstalledCondaPackages = () => { + return Promise.resolve(packages); + }; + let serverInstallation = createJupyterServerInstallation(testContext); + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + + let actual = await provider.listPackages(); + should.deepEqual(actual, packages); + }); + + it('Pip installPackages should install packages successfully', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + + await provider.installPackages(packages, true); + serverInstallation.verify(x => x.installPipPackages(packages, true), TypeMoq.Times.once()); + }); + + it('Conda installPackages should install packages successfully', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + + await provider.installPackages(packages, true); + serverInstallation.verify(x => x.installCondaPackages(packages, true), TypeMoq.Times.once()); + }); + + it('Pip uninstallPackages should install packages successfully', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + + await provider.uninstallPackages(packages); + serverInstallation.verify(x => x.uninstallPipPackages(packages), TypeMoq.Times.once()); + }); + + it('Conda uninstallPackages should install packages successfully', async function (): Promise { + let packages = [ + { + name: 'name1', + version: '1.1.1.1' + } + ]; + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + + await provider.uninstallPackages(packages); + serverInstallation.verify(x => x.uninstallCondaPackages(packages), TypeMoq.Times.once()); + }); + + it('Conda canUseProvider should return what the server is returning', async function (): Promise { + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + + should.equal(await provider.canUseProvider(), false); + }); + + it('Pip canUseProvider should return true', async function (): Promise { + let testContext = createContext(); + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + + should.equal(await provider.canUseProvider(), true); + }); + + it('Pip getPackageOverview should return package info successfully', async function (): Promise { + let testContext = createContext(); + testContext.piPyClient.fetchPypiPackage = (packageName) => { + return Promise.resolve(` + "{"info":{"summary":"package summary"}, "releases":{"0.0.1":[{"comment_text":""}], "0.0.2":[{"comment_text":""}]}"`); + }; + let serverInstallation = createJupyterServerInstallation(testContext); + let client = createPipyClient(testContext); + let provider = new LocalPipPackageManageProvider(serverInstallation.object, client.object); + + should(provider.getPackageOverview('name')).resolvedWith({ + name: 'name', + versions: ['0.0.1', '0.0.2'], + summary: 'summary' + }); + }); + + it('Conda getPackageOverview should return package info successfully', async function (): Promise { + let testContext = createContext(); + testContext.serverInstallation.executeBufferedCommand = (command) => { + return Promise.resolve(` + "{"name":[{"version":"0.0.1"}, {"version":"0.0.2}]"`); + }; + let serverInstallation = createJupyterServerInstallation(testContext); + + let provider = new LocalCondaPackageManageProvider(serverInstallation.object); + + should(provider.getPackageOverview('name')).resolvedWith({ + name: 'name', + versions: ['0.0.1', '0.0.2'], + summary: undefined + }); + }); + + function createContext(): TestContext { + return { + serverInstallation: { + installCondaPackages: (packages: PythonPkgDetails[], useMinVersion: boolean) => { return Promise.resolve(); }, + configurePackagePaths: () => { return Promise.resolve(); }, + startInstallProcess: (forceInstall: boolean, installSettings?: { installPath: string, existingPython: boolean }) => { return Promise.resolve(); }, + getInstalledPipPackages: () => { return Promise.resolve([]); }, + installPipPackages: (packages: PythonPkgDetails[], useMinVersion: boolean) => { return Promise.resolve(); }, + uninstallPipPackages: (packages: PythonPkgDetails[]) => { return Promise.resolve(); }, + getInstalledCondaPackages: () => { return Promise.resolve([]); }, + uninstallCondaPackages: (packages: PythonPkgDetails[]) => { return Promise.resolve(); }, + executeBufferedCommand: (command: string) => { return Promise.resolve(''); }, + executeStreamedCommand: (command: string) => { return Promise.resolve(); }, + getCondaExePath: () => { return ''; }, + pythonExecutable: '', + usingConda: false + }, + piPyClient: { + fetchPypiPackage: (packageName) => { return Promise.resolve(); } + } + }; + } + + function createJupyterServerInstallation(testContext: TestContext): TypeMoq.IMock { + let mockInstance = TypeMoq.Mock.ofType(JupyterServerInstallation); + mockInstance.setup(x => x.installCondaPackages(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((packages, useMinVersion) => testContext.serverInstallation.installCondaPackages(packages, useMinVersion)); + mockInstance.setup(x => x.installPipPackages(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((packages, useMinVersion) => testContext.serverInstallation.installPipPackages(packages, useMinVersion)); + mockInstance.setup(x => x.uninstallCondaPackages(TypeMoq.It.isAny())).returns((packages, useMinVersion) => testContext.serverInstallation.uninstallCondaPackages(packages)); + mockInstance.setup(x => x.uninstallPipPackages(TypeMoq.It.isAny())).returns((packages, useMinVersion) => testContext.serverInstallation.uninstallPipPackages(packages)); + mockInstance.setup(x => x.getInstalledPipPackages()).returns(() => testContext.serverInstallation.getInstalledPipPackages()); + mockInstance.setup(x => x.getInstalledCondaPackages()).returns(() => testContext.serverInstallation.getInstalledCondaPackages()); + mockInstance.setup(x => x.usingConda).returns(() => testContext.serverInstallation.usingConda); + return mockInstance; + } + + function createPipyClient(testContext: TestContext): TypeMoq.IMock { + let mockInstance = TypeMoq.Mock.ofType(PiPyClient); + mockInstance.setup(x => x.fetchPypiPackage(TypeMoq.It.isAny())).returns((packageName) => + testContext.piPyClient.fetchPypiPackage(packageName)); + return mockInstance; + } +}); diff --git a/extensions/notebook/src/test/managePackages/managePackagesDialogModel.test.ts b/extensions/notebook/src/test/managePackages/managePackagesDialogModel.test.ts new file mode 100644 index 0000000000..f706235b4c --- /dev/null +++ b/extensions/notebook/src/test/managePackages/managePackagesDialogModel.test.ts @@ -0,0 +1,351 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as should from 'should'; +import 'mocha'; +import * as TypeMoq from 'typemoq'; +import { IPackageManageProvider, IPackageDetails } from '../../types'; +import { LocalPipPackageManageProvider } from '../../jupyter/localPipPackageManageProvider'; + +import { ManagePackagesDialogModel } from '../../dialog/managePackages/managePackagesDialogModel'; +import { JupyterServerInstallation } from '../../jupyter/jupyterServerInstallation'; + +interface TestContext { + provider: IPackageManageProvider; +} + +describe('Manage Packages', () => { + let jupyterServerInstallation: JupyterServerInstallation; + beforeEach(() => { + jupyterServerInstallation = new JupyterServerInstallation(undefined, undefined, undefined, undefined); + }); + + it('Should throw exception given undefined providers', async function (): Promise { + should.throws(() => { new ManagePackagesDialogModel(jupyterServerInstallation, undefined); }, 'Invalid list of package manager providers'); + }); + + it('Should throw exception given empty providers', async function (): Promise { + let providers = new Map(); + should.throws(() => { new ManagePackagesDialogModel(jupyterServerInstallation, providers); }, 'Invalid list of package manager providers'); + }); + + it('Should not throw exception given undefined options', async function (): Promise { + let testContext = createContext(); + testContext.provider.listPackages = () => { + return Promise.resolve(undefined); + }; + let provider = createProvider(testContext); + let providers = new Map(); + providers.set(provider.providerId, provider); + + should.doesNotThrow(() => { new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); }); + }); + + it('Init should throw exception given invalid default location', async function (): Promise { + let testContext = createContext(); + let provider = createProvider(testContext); + let providers = new Map(); + providers.set(provider.providerId, provider); + + let options = { + multiLocations: true, + defaultLocation: 'invalid location' + }; + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, options); + should(model.init()).rejectedWith(`Invalid default location '${options.defaultLocation}`); + }); + + it('Init should throw exception given invalid default provider', async function (): Promise { + let testContext = createContext(); + let provider = createProvider(testContext); + let providers = new Map(); + providers.set(provider.providerId, provider); + + let options = { + multiLocations: true, + defaultProviderId: 'invalid provider' + }; + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, options); + should(model.init()).rejectedWith(`Invalid default provider id '${options.defaultProviderId}`); + }); + + it('Init should throw exception not given valid default location for single location mode', async function (): Promise { + let testContext = createContext(); + let provider = createProvider(testContext); + let providers = new Map(); + providers.set(provider.providerId, provider); + + let options = { + multiLocations: false + }; + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, options); + should(model.init()).rejectedWith(`Default location not specified for single location mode`); + }); + + + it('Init should set default options given undefined', async function (): Promise { + let testContext = createContext(); + let provider = createProvider(testContext); + let providers = new Map(); + providers.set(provider.providerId, provider); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + should.equal(model.multiLocationMode, true); + should.equal(model.defaultLocation, provider.packageTarget.location); + should.equal(model.defaultProviderId, provider.providerId); + }); + + it('Init should set default provider Id given valid options', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.packageTarget = { + location: 'location1', + packageType: 'package-type2' + }; + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + let options = { + multiLocations: false, + defaultLocation: testContext2.provider.packageTarget.location, + defaultProviderId: testContext2.provider.providerId + }; + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, options); + + await model.init(); + should.equal(model.multiLocationMode, false); + should.equal(model.defaultLocation, testContext2.provider.packageTarget.location); + should.equal(model.defaultProviderId, testContext2.provider.providerId); + }); + + it('Should create a cache for multiple providers successfully', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.packageTarget = { + location: 'location1', + packageType: 'package-type2' + }; + + let testContext3 = createContext(); + testContext3.provider.providerId = 'providerId3'; + testContext3.provider.packageTarget = { + location: 'location2', + packageType: 'package-type1' + }; + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + providers.set(testContext3.provider.providerId, createProvider(testContext3)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + should.equal(model.defaultLocation, testContext1.provider.packageTarget.location); + should.deepEqual(model.getPackageTypes('location1'), [{ providerId: 'providerId1', packageType: 'package-type1'}, {providerId: 'providerId2', packageType: 'package-type2'}]); + should.deepEqual(model.getPackageTypes('location2'), [{providerId: 'providerId3', packageType: 'package-type1'}]); + }); + + it('Should not include a provider that can not be used in current context', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.packageTarget = { + location: 'location1', + packageType: 'package-type2' + }; + testContext2.provider.canUseProvider = () => { return Promise.resolve(false); }; + + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + should.equal(model.defaultLocation, testContext1.provider.packageTarget.location); + should.deepEqual(model.getPackageTypes('location1'), [{providerId: 'providerId1', packageType: 'package-type1'}]); + }); + + it('changeProvider should change current provider successfully', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.getLocationTitle = () => Promise.resolve('location title 1'); + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.getLocationTitle = () => Promise.resolve('location title 2'); + testContext2.provider.packageTarget = { + location: 'location2', + packageType: 'package-type2' + }; + + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + model.changeProvider('providerId2'); + should.deepEqual(await model.getLocationTitle(), 'location title 2'); + }); + + it('changeProvider should throw exception given invalid provider', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.packageTarget = { + location: 'location2', + packageType: 'package-type2' + }; + + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + should.throws(() => model.changeProvider('providerId3')); + }); + + + it('currentPackageManageProvider should return undefined if current provider is not set', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.packageTarget = { + location: 'location2', + packageType: 'package-type2' + }; + + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + should.equal(model.currentPackageManageProvider, undefined); + should(model.listPackages()).rejected(); + should(model.installPackages(TypeMoq.It.isAny())).rejected(); + should(model.uninstallPackages(TypeMoq.It.isAny())).rejected(); + }); + + it('current provider should install and uninstall packages successfully', async function (): Promise { + let testContext1 = createContext(); + testContext1.provider.providerId = 'providerId1'; + testContext1.provider.packageTarget = { + location: 'location1', + packageType: 'package-type1' + }; + + let testContext2 = createContext(); + testContext2.provider.providerId = 'providerId2'; + testContext2.provider.getLocationTitle = () => Promise.resolve('location title 2'); + testContext2.provider.packageTarget = { + location: 'location2', + packageType: 'package-type2' + }; + let packages = [ + { + name: 'p1', + version: '1.1.1.1' + }, + { + name: 'p2', + version: '1.1.1.2' + } + ]; + testContext2.provider.listPackages = () => { + return Promise.resolve(packages); + }; + + let providers = new Map(); + providers.set(testContext1.provider.providerId, createProvider(testContext1)); + providers.set(testContext2.provider.providerId, createProvider(testContext2)); + + let model = new ManagePackagesDialogModel(jupyterServerInstallation, providers, undefined); + + await model.init(); + model.changeProvider('providerId2'); + should(model.listPackages()).resolvedWith(packages); + should(model.installPackages(packages)).resolved(); + should(model.uninstallPackages(packages)).resolved(); + should(model.getPackageOverview('p1')).resolved(); + should(model.getLocationTitle()).rejectedWith('location title 2'); + }); + + function createContext(): TestContext { + return { + provider: { + providerId: 'providerId', + packageTarget: { + location: 'location', + packageType: 'package-type' + }, + canUseProvider: () => { return Promise.resolve(true); }, + getLocationTitle: () => { return Promise.resolve('location-title'); }, + installPackages:() => { return Promise.resolve(); }, + uninstallPackages: (packages: IPackageDetails[]) => { return Promise.resolve(); }, + listPackages: () => { return Promise.resolve([]); }, + getPackageOverview: (name: string) => { return Promise.resolve(undefined); }, + } + }; + } + + function createProvider(testContext: TestContext): IPackageManageProvider { + let mockProvider = TypeMoq.Mock.ofType(LocalPipPackageManageProvider); + mockProvider.setup(x => x.canUseProvider()).returns(() => testContext.provider.canUseProvider()); + mockProvider.setup(x => x.getLocationTitle()).returns(() => testContext.provider.getLocationTitle()); + mockProvider.setup(x => x.installPackages(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((packages, useMinVersion) => testContext.provider.installPackages(packages, useMinVersion)); + mockProvider.setup(x => x.uninstallPackages(TypeMoq.It.isAny())).returns((packages) => testContext.provider.uninstallPackages(packages)); + mockProvider.setup(x => x.listPackages()).returns(() => testContext.provider.listPackages()); + mockProvider.setup(x => x.getPackageOverview(TypeMoq.It.isAny())).returns((name) => testContext.provider.getPackageOverview(name)); + mockProvider.setup(x => x.packageTarget).returns(() => testContext.provider.packageTarget); + mockProvider.setup(x => x.providerId).returns(() => testContext.provider.providerId); + return mockProvider.object; + } + +}); diff --git a/extensions/notebook/src/types.d.ts b/extensions/notebook/src/types.d.ts index fdb53009cd..368d221a1b 100644 --- a/extensions/notebook/src/types.d.ts +++ b/extensions/notebook/src/types.d.ts @@ -3,13 +3,130 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { JupyterController } from './jupyter/jupyterController'; - /** * The API provided by this extension. * * @export */ export interface IExtensionApi { - getJupyterController(): JupyterController; + getJupyterController(): IJupyterController; + registerPackageManager(providerId: string, packageManagerProvider: IPackageManageProvider): void; + getPackageManagers(): Map; +} + +/** + * jupyter controller interface + */ +export interface IJupyterController { + /** + * Server installation instance + */ + jupyterInstallation: IJupyterServerInstallation; +} + +export interface IJupyterServerInstallation { + /** + * Installs packages using pip + * @param packages packages to install + * @param useMinVersion if true, minimal version will be used + */ + installPipPackages(packages: IPackageDetails[], useMinVersion: boolean): Promise; + + /** + * Uninstalls packages using pip + * @param packages packages to uninstall + */ + uninstallPipPackages(packages: IPackageDetails[]): Promise; + + /** + * Installs conda packages + * @param packages packages to install + * @param useMinVersion if true, minimal version will be used + */ + installCondaPackages(packages: IPackageDetails[], useMinVersion: boolean): Promise; + + /** + * Uninstalls packages using conda + * @param packages packages to uninstall + */ + uninstallCondaPackages(packages: IPackageDetails[]): Promise; + + /** + * Returns installed pip packages + */ + getInstalledPipPackages(): Promise; +} + +/** + * Package details interface + */ +export interface IPackageDetails { + name: string; + version: string; +} + +/** + * Package target interface + */ +export interface IPackageTarget { + location: string; + packageType: string; +} + +/** + * Package overview + */ +export interface IPackageOverview { + name: string; + versions: string[]; + summary: string; +} + +/** + * Package manage provider interface + */ +export interface IPackageManageProvider { + /** + * Provider id + */ + providerId: string; + + /** + * package target + */ + packageTarget: IPackageTarget; + + /** + * Returns list of installed packages + */ + listPackages(): Promise; + + /** + * Installs give packages + * @param package Packages to install + * @param useMinVersion if true, minimal version will be used + */ + installPackages(package: IPackageDetails[], useMinVersion: boolean): Promise; + + /** + * Uninstalls given packages + * @param package package to uninstall + */ + uninstallPackages(package: IPackageDetails[]): Promise; + + /** + * Returns true if the provider can be used in current context + */ + canUseProvider(): Promise; + + /** + * Returns location title + */ + getLocationTitle(): Promise; + + /** + * Returns Package Overview + * @param packageName package name + */ + getPackageOverview(packageName: string): Promise; } diff --git a/src/sql/workbench/browser/modelComponents/dropdown.component.ts b/src/sql/workbench/browser/modelComponents/dropdown.component.ts index 7b5219cf92..693849d5d6 100644 --- a/src/sql/workbench/browser/modelComponents/dropdown.component.ts +++ b/src/sql/workbench/browser/modelComponents/dropdown.component.ts @@ -216,4 +216,12 @@ export default class DropDownComponent extends ComponentBase implements ICompone private setValuesProperties(properties: azdata.DropDownProperties, values: string[] | azdata.CategoryValue[]): void { properties.values = values; } + + public focus(): void { + if (this.editable && !this._isInAccessibilityMode) { + this._editableDropdown.focus(); + } else { + this._selectBox.focus(); + } + } }