mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-14 01:25:37 -05:00
Automatically detect existing Python/Conda installs in Configure Python dialog. (#5801)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
166
extensions/notebook/src/dialog/pythonPathLookup.ts
Normal file
166
extensions/notebook/src/dialog/pythonPathLookup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user