Automatically detect existing Python/Conda installs in Configure Python dialog. (#5801)

This commit is contained in:
Cory Rivera
2019-06-03 11:56:06 -07:00
committed by GitHub
parent a8d41a6717
commit fb713e0762
5 changed files with 300 additions and 29 deletions

View File

@@ -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",

View File

@@ -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<void>;
private setupComplete: Deferred<void>;
private pythonPathsPromise: Promise<PythonPathInfo[]>;
private usingCustomPath: boolean;
constructor(private apiWrapper: ApiWrapper, private jupyterInstallation: JupyterServerInstallation) {
this._setupComplete = new Deferred<void>();
this.setupComplete = new Deferred<void>();
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<azdata.InputBoxProperties>({
value: JupyterServerInstallation.getPythonInstallPath(this.apiWrapper),
this.pythonLocationDropdown = view.modelBuilder.dropDown()
.withProperties<azdata.DropDownProperties>({
value: undefined,
values: [],
width: '100%'
}).component();
this.pythonDropdownLoader = view.modelBuilder.loadingComponent()
.withItem(this.pythonLocationDropdown)
.withProperties<azdata.LoadingComponentProperties>({
loading: false
})
.component();
this.browseButton = view.modelBuilder.button()
.withProperties<azdata.ButtonProperties>({
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<void> {
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<azdata.RadioButtonProperties>({
@@ -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<boolean> {
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 = <azdata.CategoryValue[]>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
});
}
}

View File

@@ -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<PythonPathInfo[]> {
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<string[]> {
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<string[]> {
return new Promise<string[]>((resolve, reject) => {
glob(globPattern, (err, files) => {
if (err) {
return reject(err);
}
resolve(Array.isArray(files) ? files : []);
});
});
}
private async getPythonSuggestions(): Promise<string[]> {
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<string> {
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<PythonPathInfo[]> {
let pathsInfo = await Promise.all(pythonPaths.map(path => this.getInfoForPath(path)));
// Remove duplicate paths, and entries with missing values
let pathSet = new Set<string>();
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<PythonPathInfo> {
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;
}
}

View File

@@ -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;
});

View File

@@ -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"