diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index f45984c4a8..eab99a14ff 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -336,6 +336,7 @@ "error-ex": "^1.3.1", "figures": "^2.0.0", "fs-extra": "^5.0.0", + "glob": "^7.1.1", "node-fetch": "^2.3.0", "request": "^2.88.0", "temp-write": "^3.4.0", @@ -345,6 +346,7 @@ "devDependencies": { "@types/decompress": "^4.2.3", "@types/fs-extra": "^5.0.0", + "@types/glob": "^7.1.1", "@types/mocha": "^5.2.5", "@types/node": "^11.9.3", "@types/request": "^2.48.1", diff --git a/extensions/notebook/src/dialog/configurePythonDialog.ts b/extensions/notebook/src/dialog/configurePythonDialog.ts index d2610f5b5e..3cc505c40d 100644 --- a/extensions/notebook/src/dialog/configurePythonDialog.ts +++ b/extensions/notebook/src/dialog/configurePythonDialog.ts @@ -12,6 +12,7 @@ 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'; const localize = nls.loadMessageBundle(); @@ -21,22 +22,27 @@ export class ConfigurePythonDialog { private readonly DialogTitle = localize('configurePython.dialogName', "Configure Python for Notebooks"); private readonly InstallButtonText = localize('configurePython.okButtonText', "Install"); private readonly CancelButtonText = localize('configurePython.cancelButtonText', "Cancel"); - private readonly BrowseButtonText = localize('configurePython.browseButtonText', "Change location"); + private readonly BrowseButtonText = localize('configurePython.browseButtonText', "Browse"); private readonly LocationTextBoxTitle = localize('configurePython.locationTextBoxText', "Python Install Location"); private readonly SelectFileLabel = localize('configurePython.selectFileLabel', "Select"); private readonly InstallationNote = localize('configurePython.installNote', "This installation will take some time. It is recommended to not close the application until the installation is complete."); private 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 pythonLocationTextBox: azdata.InputBoxComponent; + private pythonLocationDropdown: azdata.DropDownComponent; + private pythonDropdownLoader: azdata.LoadingComponent; private browseButton: azdata.ButtonComponent; private newInstallButton: azdata.RadioButtonComponent; private existingInstallButton: azdata.RadioButtonComponent; - private _setupComplete: Deferred; + private setupComplete: Deferred; + private pythonPathsPromise: Promise; + private usingCustomPath: boolean; constructor(private apiWrapper: ApiWrapper, private jupyterInstallation: JupyterServerInstallation) { - this._setupComplete = new Deferred(); + this.setupComplete = new Deferred(); + this.pythonPathsPromise = (new PythonPathLookup()).getSuggestions(); + this.usingCustomPath = false; } /** @@ -53,9 +59,9 @@ export class ConfigurePythonDialog { this.dialog.cancelButton.label = this.CancelButtonText; this.dialog.cancelButton.onClick(() => { if (rejectOnCancel) { - this._setupComplete.reject(localize('configurePython.pythonInstallDeclined', "Python installation was declined.")); + this.setupComplete.reject(localize('configurePython.pythonInstallDeclined', "Python installation was declined.")); } else { - this._setupComplete.resolve(); + this.setupComplete.resolve(); } }); @@ -63,21 +69,28 @@ export class ConfigurePythonDialog { azdata.window.openDialog(this.dialog); - return this._setupComplete.promise; + return this.setupComplete.promise; } private initializeContent(): void { this.dialog.registerContent(async view => { - this.pythonLocationTextBox = view.modelBuilder.inputBox() - .withProperties({ - value: JupyterServerInstallation.getPythonInstallPath(this.apiWrapper), + this.pythonLocationDropdown = view.modelBuilder.dropDown() + .withProperties({ + value: undefined, + values: [], width: '100%' }).component(); + this.pythonDropdownLoader = view.modelBuilder.loadingComponent() + .withItem(this.pythonLocationDropdown) + .withProperties({ + loading: false + }) + .component(); this.browseButton = view.modelBuilder.button() .withProperties({ label: this.BrowseButtonText, - width: '100px' + width: '70px' }).component(); this.browseButton.onDidClick(() => this.handleBrowse()); @@ -95,7 +108,8 @@ export class ConfigurePythonDialog { } }); - this.createInstallRadioButtons(view.modelBuilder); + let useExistingPython = JupyterServerInstallation.getExistingPythonSetting(this.apiWrapper); + this.createInstallRadioButtons(view.modelBuilder, useExistingPython); let formModel = view.modelBuilder.formContainer() .withFormItems([{ @@ -105,7 +119,7 @@ export class ConfigurePythonDialog { component: this.existingInstallButton, title: '' }, { - component: this.pythonLocationTextBox, + component: this.pythonDropdownLoader, title: this.LocationTextBoxTitle }, { component: this.browseButton, @@ -116,11 +130,50 @@ export class ConfigurePythonDialog { }]).component(); await view.initializeModel(formModel); + + await this.updatePythonPathsDropdown(useExistingPython); }); } - private createInstallRadioButtons(modelBuilder: azdata.ModelBuilder): void { - let useExistingPython = JupyterServerInstallation.getExistingPythonSetting(this.apiWrapper); + private async updatePythonPathsDropdown(useExistingPython: boolean): Promise { + await this.pythonDropdownLoader.updateProperties({ loading: true }); + try { + let pythonPaths: PythonPathInfo[]; + let dropdownValues: azdata.CategoryValue[]; + if (useExistingPython) { + pythonPaths = await this.pythonPathsPromise; + if (pythonPaths && pythonPaths.length > 0) { + dropdownValues = pythonPaths.map(path => { + return { + displayName: `${path.installDir} (Python ${path.version})`, + name: path.installDir + }; + }); + } else { + dropdownValues = [{ + displayName: 'No supported Python versions found.', + name: '' + }]; + } + } else { + let defaultPath = JupyterServerInstallation.DefaultPythonLocation; + dropdownValues = [{ + displayName: `${defaultPath} (Default)`, + name: defaultPath + }]; + } + + this.usingCustomPath = false; + await this.pythonLocationDropdown.updateProperties({ + value: dropdownValues[0], + values: dropdownValues + }); + } finally { + await this.pythonDropdownLoader.updateProperties({ loading: false }); + } + } + + private createInstallRadioButtons(modelBuilder: azdata.ModelBuilder, useExistingPython: boolean): void { let buttonGroup = 'installationType'; this.newInstallButton = modelBuilder.radioButton() .withProperties({ @@ -130,6 +183,10 @@ export class ConfigurePythonDialog { }).component(); this.newInstallButton.onDidClick(() => { this.existingInstallButton.checked = false; + this.updatePythonPathsDropdown(false) + .catch(err => { + this.showErrorMessage(utils.getErrorMessage(err)); + }); }); this.existingInstallButton = modelBuilder.radioButton() @@ -140,11 +197,15 @@ export class ConfigurePythonDialog { }).component(); this.existingInstallButton.onDidClick(() => { this.newInstallButton.checked = false; + this.updatePythonPathsDropdown(true) + .catch(err => { + this.showErrorMessage(utils.getErrorMessage(err)); + }); }); } private async handleInstall(): Promise { - let pythonLocation = this.pythonLocationTextBox.value; + let pythonLocation = (this.pythonLocationDropdown.value as azdata.CategoryValue).name; if (!pythonLocation || pythonLocation.length === 0) { this.showErrorMessage(this.InvalidLocationMsg); return false; @@ -173,10 +234,10 @@ export class ConfigurePythonDialog { // Don't wait on installation, since there's currently no Cancel functionality this.jupyterInstallation.startInstallProcess(false, { installPath: pythonLocation, existingPython: useExistingPython }) .then(() => { - this._setupComplete.resolve(); + this.setupComplete.resolve(); }) .catch(err => { - this._setupComplete.reject(utils.getErrorMessage(err)); + this.setupComplete.reject(utils.getErrorMessage(err)); }); return true; @@ -216,7 +277,24 @@ export class ConfigurePythonDialog { let fileUris: vscode.Uri[] = await this.apiWrapper.showOpenDialog(options); if (fileUris && fileUris[0]) { - this.pythonLocationTextBox.value = fileUris[0].fsPath; + let existingValues = this.pythonLocationDropdown.values; + let filePath = fileUris[0].fsPath; + let newValue = { + displayName: `${filePath} (Custom)`, + 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/pythonPathLookup.ts b/extensions/notebook/src/dialog/pythonPathLookup.ts new file mode 100644 index 0000000000..9c3c6eadee --- /dev/null +++ b/extensions/notebook/src/dialog/pythonPathLookup.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as glob from 'glob'; + +import * as utils from '../common/utils'; +import * as constants from '../common/constants'; + +export interface PythonPathInfo { + installDir: string; + version: string; +} + +export class PythonPathLookup { + private condaLocations: string[]; + constructor() { + if (process.platform !== constants.winPlatform) { + let userFolder = process.env['HOME']; + this.condaLocations = [ + '/opt/*conda*/bin/python3', + '/usr/share/*conda*/bin/python3', + `${userFolder}/*conda*/bin/python3` + ]; + } else { + let userFolder = process.env['USERPROFILE'].replace('\\', '/').replace('C:', ''); + this.condaLocations = [ + '/ProgramData/[Mm]iniconda*/python.exe', + '/ProgramData/[Aa]naconda*/python.exe', + `${userFolder}/[Mm]iniconda*/python.exe`, + `${userFolder}/[Aa]naconda*/python.exe`, + `${userFolder}/AppData/Local/Continuum/[Mm]iniconda*/python.exe`, + `${userFolder}/AppData/Local/Continuum/[Aa]naconda*/python.exe` + ]; + } + } + + public async getSuggestions(): Promise { + let pythonSuggestions = await this.getPythonSuggestions(); + let condaSuggestions = await this.getCondaSuggestions(); + + if (pythonSuggestions) { + if (condaSuggestions && condaSuggestions.length > 0) { + pythonSuggestions = pythonSuggestions.concat(condaSuggestions); + } + return this.getInfoForPaths(pythonSuggestions); + } else { + return []; + } + } + + private async getCondaSuggestions(): Promise { + try { + let condaResults = await Promise.all(this.condaLocations.map(location => this.globSearch(location))); + let condaFiles = condaResults.reduce((first, second) => first.concat(second)); + return condaFiles.filter(condaPath => condaPath && condaPath.length > 0); + } catch (err) { + } + return []; + } + + private globSearch(globPattern: string): Promise { + return new Promise((resolve, reject) => { + glob(globPattern, (err, files) => { + if (err) { + return reject(err); + } + resolve(Array.isArray(files) ? files : []); + }); + }); + } + + private async getPythonSuggestions(): Promise { + let pathsToCheck = this.getPythonCommands(); + + let pythonPaths = await Promise.all(pathsToCheck.map(item => this.getPythonPath(item))); + let results: string[]; + if (pythonPaths) { + results = pythonPaths.filter(path => path && path.length > 0); + } else { + results = []; + } + return results; + } + + private async getPythonPath(options: { command: string; args?: string[] }): Promise { + try { + let args = Array.isArray(options.args) ? options.args : []; + args = args.concat(['-c', '"import sys;print(sys.executable)"']); + const cmd = `"${options.command}" ${args.join(' ')}`; + let output = await utils.executeBufferedCommand(cmd, {}); + let value = output ? output.trim() : ''; + if (value.length > 0 && fs.existsSync(value)) { + return value; + } + } catch (err) { + // Ignore errors here, since this python version will just be excluded. + } + + return undefined; + } + + private getPythonCommands(): { command: string; args?: string[] }[] { + const paths = ['python3.7', 'python3.6', 'python3', 'python'] + .map(item => { return { command: item }; }); + if (process.platform !== constants.winPlatform) { + return paths; + } + + const versions = ['3.7', '3.6', '3']; + return paths.concat(versions.map(version => { + return { command: 'py', args: [`-${version}`] }; + })); + } + + private async getInfoForPaths(pythonPaths: string[]): Promise { + let pathsInfo = await Promise.all(pythonPaths.map(path => this.getInfoForPath(path))); + + // Remove duplicate paths, and entries with missing values + let pathSet = new Set(); + return pathsInfo.filter(path => { + if (!path || !path.installDir || !path.version || path.installDir.length === 0 || path.version.length === 0) { + return false; + } + + let majorVersion = Number.parseInt(path.version.substring(0, path.version.indexOf('.'))); + if (Number.isNaN(majorVersion) || majorVersion < 3) { + return false; + } + + let key = `${path.installDir} ${path.version}`; + if (pathSet.has(key)) { + return false; + } else { + pathSet.add(key); + return true; + } + }); + } + + private async getInfoForPath(pythonPath: string): Promise { + try { + // "python --version" returns nothing from executeBufferedCommand with Python 2.X, + // so use sys.version_info here instead. + let cmd = `"${pythonPath}" -c "import sys;print('.'.join(str(i) for i in sys.version_info[:3]))"`; + let output = await utils.executeBufferedCommand(cmd, {}); + let pythonVersion = output ? output.trim() : ''; + + cmd = `"${pythonPath}" -c "import sys;print(sys.exec_prefix)"`; + output = await utils.executeBufferedCommand(cmd, {}); + let pythonPrefix = output ? output.trim() : ''; + + if (pythonVersion.length > 0 && pythonPrefix.length > 0) { + return { + installDir: pythonPrefix, + version: pythonVersion + }; + } + } catch (err) { + // Ignore errors here, since this python version will just be excluded. + } + return undefined; + } +} \ No newline at end of file diff --git a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts index bd2873b385..2a03800f9d 100644 --- a/extensions/notebook/src/jupyter/jupyterServerInstallation.ts +++ b/extensions/notebook/src/jupyter/jupyterServerInstallation.ts @@ -53,7 +53,7 @@ export default class JupyterServerInstallation { private _forceInstall: boolean; private _installInProgress: boolean; - private static readonly DefaultPythonLocation = path.join(utils.getUserHome(), 'azuredatastudio-python'); + public static readonly DefaultPythonLocation = path.join(utils.getUserHome(), 'azuredatastudio-python'); constructor(extensionPath: string, outputChannel: OutputChannel, apiWrapper: ApiWrapper, pythonInstallationPath?: string) { this.extensionPath = extensionPath; @@ -76,7 +76,12 @@ export default class JupyterServerInstallation { this.outputChannel.appendLine(msgInstallPkgProgress); backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgInstallPkgProgress); - await this.installPythonPackage(backgroundOperation); + try { + await this.installPythonPackage(backgroundOperation); + } catch (err) { + this.outputChannel.appendLine(msgDependenciesInstallationFailed(utils.getErrorMessage(err))); + throw err; + } this.outputChannel.appendLine(msgPythonDownloadComplete); backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonDownloadComplete); @@ -135,13 +140,13 @@ export default class JupyterServerInstallation { fs.mkdirs(this._pythonInstallationPath, (err) => { if (err) { backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonDirectoryError); - reject(err); + return reject(err); } let totalMegaBytes: number = undefined; let receivedBytes = 0; let printThreshold = 0.1; - request.get(pythonDownloadUrl, { timeout: 20000 }) + let downloadRequest = request.get(pythonDownloadUrl, { timeout: 20000 }) .on('error', (downloadError) => { backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonDownloadError); reject(downloadError); @@ -149,7 +154,7 @@ export default class JupyterServerInstallation { .on('response', (response) => { if (response.statusCode !== 200) { backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonDownloadError); - reject(response.statusMessage); + return reject(response.statusMessage); } let totalBytes = parseInt(response.headers['content-length']); @@ -166,18 +171,19 @@ export default class JupyterServerInstallation { printThreshold += 0.1; } } - }) - .pipe(fs.createWriteStream(pythonPackagePathLocal)) + }); + + downloadRequest.pipe(fs.createWriteStream(pythonPackagePathLocal)) .on('close', () => { //unpack python zip/tar file this.outputChannel.appendLine(msgPythonUnpackPending); let pythonSourcePath = path.join(this._pythonInstallationPath, constants.pythonBundleVersion); - if (fs.existsSync(pythonSourcePath)) { + if (!this._usingExistingPython && fs.existsSync(pythonSourcePath)) { try { fs.removeSync(pythonSourcePath); } catch (err) { backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonUnpackError); - reject(err); + return reject(err); } } decompress(pythonPackagePathLocal, this._pythonInstallationPath).then(files => { @@ -198,6 +204,7 @@ export default class JupyterServerInstallation { .on('error', (downloadError) => { backgroundOperation.updateStatus(azdata.TaskStatus.InProgress, msgPythonDownloadError); reject(downloadError); + downloadRequest.abort(); }); }); }); @@ -314,7 +321,6 @@ export default class JupyterServerInstallation { .catch(err => { let errorMsg = msgDependenciesInstallationFailed(utils.getErrorMessage(err)); op.updateStatus(azdata.TaskStatus.Failed, errorMsg); - this.apiWrapper.showErrorMessage(errorMsg); installReady.reject(errorMsg); this._installInProgress = false; }); diff --git a/extensions/notebook/yarn.lock b/extensions/notebook/yarn.lock index 784f53bef0..d6990ecd96 100644 --- a/extensions/notebook/yarn.lock +++ b/extensions/notebook/yarn.lock @@ -92,6 +92,11 @@ dependencies: "@types/node" "*" +"@types/events@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" + integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== + "@types/form-data@*": version "2.2.1" resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" @@ -106,6 +111,20 @@ dependencies: "@types/node" "*" +"@types/glob@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" + integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== + dependencies: + "@types/events" "*" + "@types/minimatch" "*" + "@types/node" "*" + +"@types/minimatch@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== + "@types/mocha@^5.2.5": version "5.2.6" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.6.tgz#b8622d50557dd155e9f2f634b7d68fd38de5e94b"