From 4372809484e374116b8616b4519e7b3c38808971 Mon Sep 17 00:00:00 2001 From: Mikhail Arkhipov Date: Wed, 3 Jan 2018 16:27:27 -0800 Subject: [PATCH] Improve interpreter selection on different platforms (#517) * Basic tokenizer * Fixed property names * Tests, round I * Tests, round II * tokenizer test * Remove temorary change * Fix merge issue * Merge conflict * Merge conflict * Completion test * Fix last line * Fix javascript math * Make test await for results * Add license headers * Rename definitions to types * License headers * Fix typo in completion details (typo) * Fix hover test * Russian translations * Update to better translation * Fix typo * #70 How to get all parameter info when filling in a function param list * Fix #70 How to get all parameter info when filling in a function param list * Clean up * Clean imports * CR feedback * Trim whitespace for test stability * More tests * Better handle no-parameters documentation * Better handle ellipsis and Python3 * Basic services * Install check * Output installer messages * Warn default Mac OS interpreter * Remove test change * Add tests * PR feedback * CR feedback * Mock process instead * Fix Brew detection * Update test * Add check suppression option & suppress vor VE by default * Fix most linter tests * Merge conflict --- package.json | 8 + .../common/application/applicationShell.ts | 60 ++++++ src/client/common/application/types.ts | 194 +++++++++++++++++ src/client/common/configSettings.ts | 3 + src/client/common/installer/installer.ts | 6 +- .../common/installer/pythonInstallation.ts | 117 ++++++++++ .../common/installer/serviceRegistry.ts | 15 ++ src/client/common/installer/types.ts | 6 +- src/client/common/platform/constants.ts | 1 + src/client/common/platform/fileSystem.ts | 73 +++++++ src/client/common/platform/pathUtils.ts | 1 + src/client/common/platform/platformService.ts | 33 +++ src/client/common/platform/serviceRegistry.ts | 17 ++ src/client/common/platform/types.ts | 23 +- src/client/common/serviceRegistry.ts | 16 +- src/client/common/terminal/service.ts | 7 +- src/client/extension.ts | 10 + src/client/formatters/baseFormatter.ts | 2 - src/client/interpreter/index.ts | 5 +- src/client/interpreter/locators/index.ts | 14 +- src/client/linters/baseLinter.ts | 2 - src/client/unittests/common/runner.ts | 2 - src/client/workspaceSymbols/main.ts | 19 +- src/test/install/pythonInstallation.test.ts | 203 ++++++++++++++++++ src/test/linters/lint.test.ts | 2 +- 25 files changed, 788 insertions(+), 51 deletions(-) create mode 100644 src/client/common/application/applicationShell.ts create mode 100644 src/client/common/application/types.ts create mode 100644 src/client/common/installer/pythonInstallation.ts create mode 100644 src/client/common/installer/serviceRegistry.ts create mode 100644 src/client/common/platform/fileSystem.ts create mode 100644 src/client/common/platform/platformService.ts create mode 100644 src/client/common/platform/serviceRegistry.ts create mode 100644 src/test/install/pythonInstallation.test.ts diff --git a/package.json b/package.json index a87114e4ad68..4b037fccc06c 100644 --- a/package.json +++ b/package.json @@ -919,6 +919,12 @@ }, "scope": "resource" }, + "python.disableInstallationCheck": { + "type": "boolean", + "default": false, + "description": "Whether to check if Python is installed.", + "scope": "resource" + }, "python.linting.enabled": { "type": "boolean", "default": true, @@ -1537,6 +1543,7 @@ "lodash": "^4.17.4", "minimatch": "^3.0.3", "named-js-regexp": "^1.3.1", + "opn": "^5.1.0", "reflect-metadata": "^0.1.10", "rxjs": "^5.5.2", "semver": "^5.4.1", @@ -1600,6 +1607,7 @@ "tslint": "^5.7.0", "tslint-eslint-rules": "^4.1.1", "tslint-microsoft-contrib": "^5.0.1", + "typemoq": "^2.1.0", "typescript": "^2.6.2", "typescript-formatter": "^6.0.0", "vscode": "^1.1.5", diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts new file mode 100644 index 000000000000..d905d1085741 --- /dev/null +++ b/src/client/common/application/applicationShell.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +// tslint:disable-next-line:no-require-imports no-var-requires +const opn = require('opn'); + +import { injectable } from 'inversify'; +import * as vscode from 'vscode'; +import { IApplicationShell } from './types'; + +@injectable() +export class ApplicationShell implements IApplicationShell { + public showInformationMessage(message: string, ...items: string[]): Thenable ; + public showInformationMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable ; + public showInformationMessage(message: string, ...items: T[]): Thenable ; + public showInformationMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable ; + // tslint:disable-next-line:no-any + public showInformationMessage(message: string, options?: any, ...items: any[]): Thenable { + return vscode.window.showInformationMessage(message, options, ...items); + } + + public showWarningMessage(message: string, ...items: string[]): Thenable; + public showWarningMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable; + public showWarningMessage(message: string, ...items: T[]): Thenable; + public showWarningMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable; + // tslint:disable-next-line:no-any + public showWarningMessage(message: any, options?: any, ...items: any[]) { + return vscode.window.showWarningMessage(message, options, ...items); + } + + public showErrorMessage(message: string, ...items: string[]): Thenable; + public showErrorMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable; + public showErrorMessage(message: string, ...items: T[]): Thenable; + public showErrorMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable; + // tslint:disable-next-line:no-any + public showErrorMessage(message: any, options?: any, ...items: any[]) { + return vscode.window.showErrorMessage(message, options, ...items); + } + + public showQuickPick(items: string[] | Thenable, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable; + public showQuickPick(items: T[] | Thenable, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable; + // tslint:disable-next-line:no-any + public showQuickPick(items: any, options?: any, token?: any) { + return vscode.window.showQuickPick(items, options, token); + } + + public showOpenDialog(options: vscode.OpenDialogOptions): Thenable { + return vscode.window.showOpenDialog(options); + } + public showSaveDialog(options: vscode.SaveDialogOptions): Thenable { + return vscode.window.showSaveDialog(options); + } + public showInputBox(options?: vscode.InputBoxOptions, token?: vscode.CancellationToken): Thenable { + return vscode.window.showInputBox(options, token); + } + public openUrl(url: string): void { + opn(url); + } +} diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts new file mode 100644 index 000000000000..4977256d0af3 --- /dev/null +++ b/src/client/common/application/types.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import * as vscode from 'vscode'; + +export const IApplicationShell = Symbol('IApplicationShell'); +export interface IApplicationShell { + showInformationMessage(message: string, ...items: string[]): Thenable; + + /** + * Show an information message to users. Optionally provide an array of items which will be presented as + * clickable buttons. + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showInformationMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable; + + /** + * Show an information message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showInformationMessage(message: string, ...items: T[]): Thenable; + + /** + * Show an information message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showInformationMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable; + + /** + * Show a warning message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showWarningMessage(message: string, ...items: string[]): Thenable; + + /** + * Show a warning message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showWarningMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable; + + /** + * Show a warning message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showWarningMessage(message: string, ...items: T[]): Thenable; + + /** + * Show a warning message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showWarningMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable; + + /** + * Show an error message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showErrorMessage(message: string, ...items: string[]): Thenable; + + /** + * Show an error message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showErrorMessage(message: string, options: vscode.MessageOptions, ...items: string[]): Thenable; + + /** + * Show an error message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showErrorMessage(message: string, ...items: T[]): Thenable; + + /** + * Show an error message. + * + * @see [showInformationMessage](#window.showInformationMessage) + * + * @param message The message to show. + * @param options Configures the behaviour of the message. + * @param items A set of items that will be rendered as actions in the message. + * @return A thenable that resolves to the selected item or `undefined` when being dismissed. + */ + showErrorMessage(message: string, options: vscode.MessageOptions, ...items: T[]): Thenable; + + /** + * Shows a selection list. + * + * @param items An array of strings, or a promise that resolves to an array of strings. + * @param options Configures the behavior of the selection list. + * @param token A token that can be used to signal cancellation. + * @return A promise that resolves to the selection or `undefined`. + */ + showQuickPick(items: string[] | Thenable, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable; + + /** + * Shows a selection list. + * + * @param items An array of items, or a promise that resolves to an array of items. + * @param options Configures the behavior of the selection list. + * @param token A token that can be used to signal cancellation. + * @return A promise that resolves to the selected item or `undefined`. + */ + showQuickPick(items: T[] | Thenable, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): Thenable; + + /** + * Shows a file open dialog to the user which allows to select a file + * for opening-purposes. + * + * @param options Options that control the dialog. + * @returns A promise that resolves to the selected resources or `undefined`. + */ + showOpenDialog(options: vscode.OpenDialogOptions): Thenable; + + /** + * Shows a file save dialog to the user which allows to select a file + * for saving-purposes. + * + * @param options Options that control the dialog. + * @returns A promise that resolves to the selected resource or `undefined`. + */ + showSaveDialog(options: vscode.SaveDialogOptions): Thenable; + + /** + * Opens an input box to ask the user for input. + * + * The returned value will be `undefined` if the input box was canceled (e.g. pressing ESC). Otherwise the + * returned value will be the string typed by the user or an empty string if the user did not type + * anything but dismissed the input box with OK. + * + * @param options Configures the behavior of the input box. + * @param token A token that can be used to signal cancellation. + * @return A promise that resolves to a string the user provided or to `undefined` in case of dismissal. + */ + showInputBox(options?: vscode.InputBoxOptions, token?: vscode.CancellationToken): Thenable; + + /** + * Opens URL in a default browser. + * + * @param url Url to open. + */ + openUrl(url: string): void; +} diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 6fd7d3ffa818..1aa7b007ac43 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -26,6 +26,7 @@ export interface IPythonSettings { workspaceSymbols: IWorkspaceSymbolSettings; envFile: string; disablePromptForFeatures: string[]; + disableInstallationChecks: boolean; } export interface ISortImportSettings { path: string; @@ -145,6 +146,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings { public terminal: ITerminalSettings; public sortImports: ISortImportSettings; public workspaceSymbols: IWorkspaceSymbolSettings; + public disableInstallationChecks: boolean; private workspaceRoot: vscode.Uri; private disposables: vscode.Disposable[] = []; @@ -222,6 +224,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings { } else { this.linting = lintingSettings; } + this.disableInstallationChecks = pythonSettings.get('disableInstallationCheck') === true; // tslint:disable-next-line:no-backbone-get-set-outside-model no-non-null-assertion const sortImportSettings = systemVariables.resolveAny(pythonSettings.get('sortImports'))!; if (this.sortImports) { diff --git a/src/client/common/installer/installer.ts b/src/client/common/installer/installer.ts index c232047e141e..d73e97179fee 100644 --- a/src/client/common/installer/installer.ts +++ b/src/client/common/installer/installer.ts @@ -9,6 +9,7 @@ import { ILinterHelper } from '../../linters/types'; import { ITestsHelper } from '../../unittests/common/types'; import { PythonSettings } from '../configSettings'; import { STANDARD_OUTPUT_CHANNEL } from '../constants'; +import { IPlatformService } from '../platform/types'; import { IProcessService, IPythonExecutionFactory } from '../process/types'; import { ITerminalService } from '../terminal/types'; import { IInstaller, ILogger, InstallerResponse, IOutputChannel, IsWindows, ModuleNamePurpose, Product } from '../types'; @@ -82,8 +83,7 @@ ProductTypes.set(Product.rope, ProductType.RefactoringLibrary); @injectable() export class Installer implements IInstaller { constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, - @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private outputChannel: vscode.OutputChannel, - @inject(IsWindows) private isWindows: boolean) { + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private outputChannel: vscode.OutputChannel) { } // tslint:disable-next-line:no-empty public dispose() { } @@ -229,7 +229,7 @@ export class Installer implements IInstaller { return disablePromptForFeatures.indexOf(productName) === -1; } private installCTags() { - if (this.isWindows) { + if (this.serviceContainer.get(IPlatformService).isWindows) { this.outputChannel.appendLine('Install Universal Ctags Win32 to enable support for Workspace Symbols'); this.outputChannel.appendLine('Download the CTags binary from the Universal CTags site.'); this.outputChannel.appendLine('Option 1: Extract ctags.exe from the downloaded zip to any folder within your PATH so that Visual Studio Code can run it.'); diff --git a/src/client/common/installer/pythonInstallation.ts b/src/client/common/installer/pythonInstallation.ts new file mode 100644 index 000000000000..6f96a62bdb41 --- /dev/null +++ b/src/client/common/installer/pythonInstallation.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { OutputChannel } from 'vscode'; +import { IInterpreterLocatorService, INTERPRETER_LOCATOR_SERVICE, InterpreterType } from '../../interpreter/contracts'; +import { IServiceContainer } from '../../ioc/types'; +import { IApplicationShell } from '../application/types'; +import { IPythonSettings } from '../configSettings'; +import { STANDARD_OUTPUT_CHANNEL } from '../constants'; +import { IFileSystem, IPlatformService } from '../platform/types'; +import { IProcessService } from '../process/types'; +import { IOutputChannel } from '../types'; + +export class PythonInstaller { + private locator: IInterpreterLocatorService; + private process: IProcessService; + private fs: IFileSystem; + private outputChannel: OutputChannel; + private _platform: IPlatformService; + private _shell: IApplicationShell; + + constructor(private serviceContainer: IServiceContainer) { + this.locator = serviceContainer.get(IInterpreterLocatorService, INTERPRETER_LOCATOR_SERVICE); + } + + public async checkPythonInstallation(settings: IPythonSettings): Promise { + if (settings.disableInstallationChecks === true) { + return true; + } + let interpreters = await this.locator.getInterpreters(); + if (interpreters.length > 0) { + if (this.platform.isMac && + settings.pythonPath === 'python' && + interpreters[0].type === InterpreterType.Unknown) { + await this.shell.showWarningMessage('Selected interpreter is MacOS system Python which is not recommended. Please select different interpreter'); + } + return true; + } + + if (!this.platform.isMac) { + // Windows or Linux + await this.shell.showErrorMessage('Python is not installed. Please download and install Python before using the extension.'); + this.shell.openUrl('https://www.python.org/downloads'); + return false; + } + + this.process = this.serviceContainer.get(IProcessService); + this.fs = this.serviceContainer.get(IFileSystem); + this.outputChannel = this.serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); + + if (this.platform.isMac) { + if (await this.shell.showErrorMessage('Python that comes with MacOS is not supported. Would you like to install regular Python now?', 'Yes', 'No') === 'Yes') { + const brewInstalled = await this.ensureBrew(); + if (!brewInstalled) { + await this.shell.showErrorMessage('Unable to install Homebrew package manager. Try installing it manually.'); + this.shell.openUrl('https://brew.sh'); + return false; + } + await this.executeAndOutput('brew', ['install', 'python']); + } + } + + interpreters = await this.locator.getInterpreters(); + return interpreters.length > 0; + } + + private isBrewInstalled(): Promise { + return this.fs.fileExistsAsync('/usr/local/bin/brew'); + } + + private async ensureBrew(): Promise { + if (await this.isBrewInstalled()) { + return true; + } + const result = await this.executeAndOutput( + '/usr/bin/ruby', + ['-e', '"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"']); + return result && await this.isBrewInstalled(); + } + + private executeAndOutput(command: string, args: string[]): Promise { + let failed = false; + this.outputChannel.show(); + + const result = this.process.execObservable(command, args, { mergeStdOutErr: true, throwOnStdErr: false }); + result.out.subscribe(output => { + this.outputChannel.append(output.out); + }, error => { + failed = true; + this.shell.showErrorMessage(`Unable to execute '${command}', error: ${error}`); + }); + + return new Promise((resolve, reject) => { + if (failed) { + resolve(false); + } + result.proc.on('exit', (code, signal) => { + resolve(!signal); + }); + }); + } + + private get shell(): IApplicationShell { + if (!this._shell) { + this._shell = this.serviceContainer.get(IApplicationShell); + } + return this._shell; + } + + private get platform(): IPlatformService { + if (!this._platform) { + this._platform = this.serviceContainer.get(IPlatformService); + } + return this._platform; + } +} diff --git a/src/client/common/installer/serviceRegistry.ts b/src/client/common/installer/serviceRegistry.ts new file mode 100644 index 000000000000..d89a81c7ad3f --- /dev/null +++ b/src/client/common/installer/serviceRegistry.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { IServiceManager } from '../../ioc/types'; +import { IInstaller } from '../types'; +import { CondaInstaller } from './condaInstaller'; +import { Installer } from './installer'; +import { PipInstaller } from './pipInstaller'; +import { IModuleInstaller, IPythonInstallation } from './types'; + +export function registerTypes(serviceManager: IServiceManager) { + serviceManager.addSingleton(IModuleInstaller, CondaInstaller); + serviceManager.addSingleton(IModuleInstaller, PipInstaller); +} diff --git a/src/client/common/installer/types.ts b/src/client/common/installer/types.ts index 0d95992254fa..a1759606de8b 100644 --- a/src/client/common/installer/types.ts +++ b/src/client/common/installer/types.ts @@ -4,9 +4,13 @@ import { Uri } from 'vscode'; export const IModuleInstaller = Symbol('IModuleInstaller'); - export interface IModuleInstaller { readonly displayName: string; installModule(name: string): Promise; isSupported(resource?: Uri): Promise; } + +export const IPythonInstallation = Symbol('IPythonInstallation'); +export interface IPythonInstallation { + checkInstallation(): Promise; +} diff --git a/src/client/common/platform/constants.ts b/src/client/common/platform/constants.ts index 26b3800134b6..3109c18110c5 100644 --- a/src/client/common/platform/constants.ts +++ b/src/client/common/platform/constants.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as arch from 'arch'; +// TO DO: Deprecate in favor of IPlatformService export const WINDOWS_PATH_VARIABLE_NAME = 'Path'; export const NON_WINDOWS_PATH_VARIABLE_NAME = 'PATH'; export const IS_WINDOWS = /^win/.test(process.platform); diff --git a/src/client/common/platform/fileSystem.ts b/src/client/common/platform/fileSystem.ts new file mode 100644 index 000000000000..bfe38a2ab836 --- /dev/null +++ b/src/client/common/platform/fileSystem.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import * as fs from 'fs'; +import * as fse from 'fs-extra'; +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { IServiceContainer } from '../../ioc/types'; +import { IFileSystem, IPlatformService } from './types'; + +@injectable() +export class FileSystem implements IFileSystem { + constructor( @inject(IServiceContainer) private platformService: IPlatformService) { } + + public get directorySeparatorChar(): string { + return path.sep; + } + + public objectExistsAsync(filePath: string, statCheck: (s: fs.Stats) => boolean): Promise { + return new Promise(resolve => { + fse.stat(filePath, (error, stats) => { + if (error) { + return resolve(false); + } + return resolve(statCheck(stats)); + }); + }); + } + + public fileExistsAsync(filePath: string): Promise { + return this.objectExistsAsync(filePath, (stats) => stats.isFile()); + } + + public directoryExistsAsync(filePath: string): Promise { + return this.objectExistsAsync(filePath, (stats) => stats.isDirectory()); + } + + public createDirectoryAsync(directoryPath: string): Promise { + return fse.mkdirp(directoryPath); + } + + public getSubDirectoriesAsync(rootDir: string): Promise { + return new Promise(resolve => { + fs.readdir(rootDir, (error, files) => { + if (error) { + return resolve([]); + } + const subDirs = []; + files.forEach(name => { + const fullPath = path.join(rootDir, name); + try { + if (fs.statSync(fullPath).isDirectory()) { + subDirs.push(fullPath); + } + // tslint:disable-next-line:no-empty + } catch (ex) { } + }); + resolve(subDirs); + }); + }); + } + + public arePathsSame(path1: string, path2: string): boolean { + path1 = path.normalize(path1); + path2 = path.normalize(path2); + if (this.platformService.isWindows) { + return path1.toUpperCase() === path2.toUpperCase(); + } else { + return path1 === path2; + } + } +} diff --git a/src/client/common/platform/pathUtils.ts b/src/client/common/platform/pathUtils.ts index 893bdae38c7d..8743cd51d88a 100644 --- a/src/client/common/platform/pathUtils.ts +++ b/src/client/common/platform/pathUtils.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'inversify'; import { IPathUtils, IsWindows } from '../types'; import { NON_WINDOWS_PATH_VARIABLE_NAME, WINDOWS_PATH_VARIABLE_NAME } from './constants'; +// TO DO: Deprecate in favor of IPlatformService @injectable() export class PathUtils implements IPathUtils { constructor( @inject(IsWindows) private isWindows: boolean) { } diff --git a/src/client/common/platform/platformService.ts b/src/client/common/platform/platformService.ts new file mode 100644 index 000000000000..f3684372ce19 --- /dev/null +++ b/src/client/common/platform/platformService.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { injectable } from 'inversify'; +import { arch } from 'os'; +import { NON_WINDOWS_PATH_VARIABLE_NAME, WINDOWS_PATH_VARIABLE_NAME } from './constants'; +import { IPlatformService } from './types'; + +@injectable() +export class PlatformService implements IPlatformService { + private _isWindows: boolean; + private _isMac: boolean; + + constructor() { + this._isWindows = /^win/.test(process.platform); + this._isMac = /^darwin/.test(process.platform); + } + public get isWindows(): boolean { + return this._isWindows; + } + public get isMac(): boolean { + return this._isMac; + } + public get isLinux(): boolean { + return !(this.isWindows || this.isMac); + } + public get is64bit(): boolean { + return arch() === 'x64'; + } + public get pathVariableName() { + return this.isWindows ? WINDOWS_PATH_VARIABLE_NAME : NON_WINDOWS_PATH_VARIABLE_NAME; + }} diff --git a/src/client/common/platform/serviceRegistry.ts b/src/client/common/platform/serviceRegistry.ts new file mode 100644 index 000000000000..dce3511f9f37 --- /dev/null +++ b/src/client/common/platform/serviceRegistry.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import { IServiceManager } from '../../ioc/types'; +import { FileSystem } from './fileSystem'; +import { PlatformService } from './platformService'; +import { RegistryImplementation } from './registry'; +import { IFileSystem, IPlatformService, IRegistry } from './types'; + +export function registerTypes(serviceManager: IServiceManager) { + serviceManager.addSingleton(IPlatformService, PlatformService); + serviceManager.addSingleton(IFileSystem, FileSystem); + if (serviceManager.get(IPlatformService).isWindows) { + serviceManager.addSingleton(IRegistry, RegistryImplementation); + } +} diff --git a/src/client/common/platform/types.ts b/src/client/common/platform/types.ts index b84cc7d9727f..58f940afdc53 100644 --- a/src/client/common/platform/types.ts +++ b/src/client/common/platform/types.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as fs from 'fs'; + export enum Architecture { Unknown = 1, x86 = 2, @@ -11,8 +13,27 @@ export enum RegistryHive { } export const IRegistry = Symbol('IRegistry'); - export interface IRegistry { getKeys(key: string, hive: RegistryHive, arch?: Architecture): Promise; getValue(key: string, hive: RegistryHive, arch?: Architecture, name?: string): Promise; } + +export const IPlatformService = Symbol('IPlatformService'); +export interface IPlatformService { + isWindows: boolean; + isMac: boolean; + isLinux: boolean; + is64bit: boolean; + pathVariableName: 'Path' | 'PATH'; +} + +export const IFileSystem = Symbol('IFileSystem'); +export interface IFileSystem { + directorySeparatorChar: string; + objectExistsAsync(path: string, statCheck: (s: fs.Stats) => boolean): Promise; + fileExistsAsync(path: string): Promise; + directoryExistsAsync(path: string): Promise; + createDirectoryAsync(path: string): Promise; + getSubDirectoriesAsync(rootDir: string): Promise; + arePathsSame(path1: string, path2: string): boolean; +} diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 101355280db1..c9bee5e8aba2 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -2,16 +2,13 @@ // Licensed under the MIT License. import { IServiceManager } from '../ioc/types'; -import { CondaInstaller } from './installer/condaInstaller'; +import { ApplicationShell } from './application/applicationShell'; +import { IApplicationShell } from './application/types'; import { Installer } from './installer/installer'; -import { PipInstaller } from './installer/pipInstaller'; -import { IModuleInstaller } from './installer/types'; import { Logger } from './logger'; import { PersistentStateFactory } from './persistentState'; import { IS_64_BIT, IS_WINDOWS } from './platform/constants'; import { PathUtils } from './platform/pathUtils'; -import { RegistryImplementation } from './platform/registry'; -import { IRegistry } from './platform/types'; import { CurrentProcess } from './process/currentProcess'; import { TerminalService } from './terminal/service'; import { ITerminalService } from './terminal/types'; @@ -22,15 +19,10 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingletonInstance(Is64Bit, IS_64_BIT); serviceManager.addSingleton(IPersistentStateFactory, PersistentStateFactory); - serviceManager.addSingleton(IInstaller, Installer); - serviceManager.addSingleton(IModuleInstaller, CondaInstaller); - serviceManager.addSingleton(IModuleInstaller, PipInstaller); serviceManager.addSingleton(ILogger, Logger); serviceManager.addSingleton(ITerminalService, TerminalService); serviceManager.addSingleton(IPathUtils, PathUtils); + serviceManager.addSingleton(IApplicationShell, ApplicationShell); serviceManager.addSingleton(ICurrentProcess, CurrentProcess); - - if (IS_WINDOWS) { - serviceManager.addSingleton(IRegistry, RegistryImplementation); - } + serviceManager.addSingleton(IInstaller, Installer); } diff --git a/src/client/common/terminal/service.ts b/src/client/common/terminal/service.ts index 62f541169255..322ad47e9152 100644 --- a/src/client/common/terminal/service.ts +++ b/src/client/common/terminal/service.ts @@ -4,7 +4,8 @@ import { inject, injectable } from 'inversify'; import { Disposable, Terminal, Uri, window, workspace } from 'vscode'; import { IServiceContainer } from '../../ioc/types'; -import { IDisposableRegistry, IsWindows } from '../types'; +import { IPlatformService } from '../platform/types'; +import { IDisposableRegistry } from '../types'; import { ITerminalService } from './types'; const IS_POWERSHELL = /powershell.exe$/i; @@ -55,8 +56,8 @@ export class TerminalService implements ITerminalService { } private terminalIsPowershell(resource?: Uri) { - const isWindows = this.serviceContainer.get(IsWindows); - if (!isWindows) { + const platform = this.serviceContainer.get(IPlatformService); + if (!platform.isWindows) { return false; } // tslint:disable-next-line:no-backbone-get-set-outside-model diff --git a/src/client/extension.ts b/src/client/extension.ts index 2cf5057b8022..4dfb091988c4 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -11,9 +11,13 @@ import * as vscode from 'vscode'; import { Disposable, Memento, OutputChannel, window } from 'vscode'; import { BannerService } from './banner'; import * as settings from './common/configSettings'; +import { PythonSettings } from './common/configSettings'; import { STANDARD_OUTPUT_CHANNEL } from './common/constants'; import { FeatureDeprecationManager } from './common/featureDeprecationManager'; import { createDeferred } from './common/helpers'; +import { PythonInstaller } from './common/installer/pythonInstallation'; +import { registerTypes as installerRegisterTypes } from './common/installer/serviceRegistry'; +import { registerTypes as platformRegisterTypes } from './common/platform/serviceRegistry'; import { registerTypes as processRegisterTypes } from './common/process/serviceRegistry'; import { IProcessService, IPythonExecutionFactory } from './common/process/types'; import { registerTypes as commonRegisterTypes } from './common/serviceRegistry'; @@ -82,6 +86,8 @@ export async function activate(context: vscode.ExtensionContext) { lintersRegisterTypes(serviceManager); interpretersRegisterTypes(serviceManager); formattersRegisterTypes(serviceManager); + platformRegisterTypes(serviceManager); + installerRegisterTypes(serviceManager); const persistentStateFactory = serviceManager.get(IPersistentStateFactory); const pythonSettings = settings.PythonSettings.getInstance(); @@ -89,6 +95,10 @@ export async function activate(context: vscode.ExtensionContext) { sortImports.activate(context, standardOutputChannel, serviceContainer); const interpreterManager = new InterpreterManager(serviceContainer); + + const pythonInstaller = new PythonInstaller(serviceContainer); + await pythonInstaller.checkPythonInstallation(PythonSettings.getInstance()); + // This must be completed before we can continue. await interpreterManager.autoSetInterpreter(); diff --git a/src/client/formatters/baseFormatter.ts b/src/client/formatters/baseFormatter.ts index 55cf10a45bc7..8d6dcda331f0 100644 --- a/src/client/formatters/baseFormatter.ts +++ b/src/client/formatters/baseFormatter.ts @@ -5,9 +5,7 @@ import { OutputChannel, TextEdit, Uri } from 'vscode'; import { STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import { isNotInstalledError } from '../common/helpers'; import { IPythonToolExecutionService } from '../common/process/types'; -import { IProcessService, IPythonExecutionFactory } from '../common/process/types'; import { IInstaller, IOutputChannel, Product } from '../common/types'; -import { IEnvironmentVariablesProvider } from '../common/variables/types'; import { IServiceContainer } from '../ioc/types'; import { getTempFileWithDocumentContents, getTextEditsFromPatch } from './../common/editor'; import { IFormatterHelper } from './types'; diff --git a/src/client/interpreter/index.ts b/src/client/interpreter/index.ts index 9a120f6895a5..4291269b6537 100644 --- a/src/client/interpreter/index.ts +++ b/src/client/interpreter/index.ts @@ -49,16 +49,17 @@ export class InterpreterManager implements Disposable { const virtualEnvInterpreterProvider = new VirtualEnvService([activeWorkspace.folderUri.fsPath], virtualEnvMgr, versionService); const interpreters = await virtualEnvInterpreterProvider.getInterpreters(activeWorkspace.folderUri); const workspacePathUpper = activeWorkspace.folderUri.fsPath.toUpperCase(); + const interpretersInWorkspace = interpreters.filter(interpreter => interpreter.path.toUpperCase().startsWith(workspacePathUpper)); - // Always pick the first available one. if (interpretersInWorkspace.length === 0) { return; } + // Always pick the highest version by default. // Ensure this new environment is at the same level as the current workspace. // In windows the interpreter is under scripts/python.exe on linux it is under bin/python. // Meaning the sub directory must be either scripts, bin or other (but only one level deep). - const pythonPath = interpretersInWorkspace[0].path; + const pythonPath = interpretersInWorkspace.sort((a, b) => a.version > b.version ? 1 : -1)[0].path; const relativePath = path.dirname(pythonPath).substring(activeWorkspace.folderUri.fsPath.length); if (relativePath.split(path.sep).filter(l => l.length > 0).length === 2) { await this.pythonPathUpdaterService.updatePythonPath(pythonPath, activeWorkspace.configTarget, 'load', activeWorkspace.folderUri); diff --git a/src/client/interpreter/locators/index.ts b/src/client/interpreter/locators/index.ts index de90e042a01f..f742647bfdb0 100644 --- a/src/client/interpreter/locators/index.ts +++ b/src/client/interpreter/locators/index.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'inversify'; import * as _ from 'lodash'; import * as path from 'path'; import { Disposable, Uri, workspace } from 'vscode'; +import { IPlatformService } from '../../common/platform/types'; import { IDisposableRegistry, IsWindows } from '../../common/types'; import { arePathsSame } from '../../common/utils'; import { IServiceContainer } from '../../ioc/types'; @@ -22,11 +23,13 @@ import { fixInterpreterDisplayName } from './helpers'; export class PythonInterpreterLocatorService implements IInterpreterLocatorService { private interpretersPerResource: Map>; private disposables: Disposable[] = []; - constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, - @inject(IsWindows) private isWindows: boolean) { + private platform: IPlatformService; + + constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer) { this.interpretersPerResource = new Map>(); this.disposables.push(workspace.onDidChangeConfiguration(this.onConfigChanged, this)); serviceContainer.get(IDisposableRegistry).push(this); + this.platform = serviceContainer.get(IPlatformService); } public async getInterpreters(resource?: Uri) { const resourceKey = this.getResourceKey(resource); @@ -59,6 +62,9 @@ export class PythonInterpreterLocatorService implements IInterpreterLocatorServi .map(fixInterpreterDisplayName) .map(item => { item.path = path.normalize(item.path); return item; }) .reduce((accumulator, current) => { + if (this.platform.isMac && current.path === '/usr/bin/python') { + return accumulator; + } const existingItem = accumulator.find(item => arePathsSame(item.path, current.path)); if (!existingItem) { accumulator.push(current); @@ -74,14 +80,14 @@ export class PythonInterpreterLocatorService implements IInterpreterLocatorServi private getLocators(resource?: Uri) { const locators: IInterpreterLocatorService[] = []; // The order of the services is important. - if (this.isWindows) { + if (this.platform.isWindows) { locators.push(this.serviceContainer.get(IInterpreterLocatorService, WINDOWS_REGISTRY_SERVICE)); } locators.push(this.serviceContainer.get(IInterpreterLocatorService, CONDA_ENV_SERVICE)); locators.push(this.serviceContainer.get(IInterpreterLocatorService, CONDA_ENV_FILE_SERVICE)); locators.push(this.serviceContainer.get(IInterpreterLocatorService, VIRTUAL_ENV_SERVICE)); - if (!this.isWindows) { + if (!this.platform.isWindows) { locators.push(this.serviceContainer.get(IInterpreterLocatorService, KNOWN_PATH_SERVICE)); } locators.push(this.serviceContainer.get(IInterpreterLocatorService, CURRENT_PATH_SERVICE)); diff --git a/src/client/linters/baseLinter.ts b/src/client/linters/baseLinter.ts index 45c94ad4be20..c7e1555ad709 100644 --- a/src/client/linters/baseLinter.ts +++ b/src/client/linters/baseLinter.ts @@ -4,9 +4,7 @@ import { CancellationToken, OutputChannel, TextDocument, Uri } from 'vscode'; import { IPythonSettings, PythonSettings } from '../common/configSettings'; import '../common/extensions'; import { IPythonToolExecutionService } from '../common/process/types'; -import { ExecutionResult, IProcessService, IPythonExecutionFactory } from '../common/process/types'; import { ExecutionInfo, IInstaller, ILogger, Product } from '../common/types'; -import { IEnvironmentVariablesProvider } from '../common/variables/types'; import { IServiceContainer } from '../ioc/types'; import { ErrorHandler } from './errorHandlers/main'; import { ILinterHelper, LinterId } from './types'; diff --git a/src/client/unittests/common/runner.ts b/src/client/unittests/common/runner.ts index 845d6a67d38d..31d08a48f784 100644 --- a/src/client/unittests/common/runner.ts +++ b/src/client/unittests/common/runner.ts @@ -5,14 +5,12 @@ import { ErrorUtils } from '../../common/errors/errorUtils'; import { ModuleNotInstalledError } from '../../common/errors/moduleNotInstalledError'; import { IPythonToolExecutionService } from '../../common/process/types'; import { - IProcessService, IPythonExecutionFactory, IPythonExecutionService, ObservableExecutionResult, SpawnOptions } from '../../common/process/types'; import { ExecutionInfo } from '../../common/types'; -import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { IServiceContainer } from '../../ioc/types'; import { NOSETEST_PROVIDER, PYTEST_PROVIDER, UNITTEST_PROVIDER } from './constants'; import { ITestsHelper, TestProvider } from './types'; diff --git a/src/client/workspaceSymbols/main.ts b/src/client/workspaceSymbols/main.ts index 1690ede4f38c..9077e33a09d1 100644 --- a/src/client/workspaceSymbols/main.ts +++ b/src/client/workspaceSymbols/main.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { OutputChannel, workspace } from 'vscode'; -import { Commands, PythonLanguage, STANDARD_OUTPUT_CHANNEL } from '../common/constants'; +import { Commands, STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import { isNotInstalledError } from '../common/helpers'; import { IProcessService } from '../common/process/types'; import { IInstaller, InstallerResponse, IOutputChannel, Product } from '../common/types'; @@ -48,23 +48,6 @@ export class WorkspaceSymbols implements vscode.Disposable { return Promise.all(promises); })); } - private registerOnSaveHandlers() { - this.disposables.push(vscode.workspace.onDidSaveTextDocument(this.onDidSaveTextDocument.bind(this))); - } - private onDidSaveTextDocument(textDocument: vscode.TextDocument) { - if (textDocument.languageId === PythonLanguage.language) { - this.rebuildTags(); - } - } - private rebuildTags() { - if (this.timeout) { - clearTimeout(this.timeout!); - this.timeout = null; - } - this.timeout = setTimeout(() => { - this.buildWorkspaceSymbols(true); - }, 5000); - } // tslint:disable-next-line:no-any private buildWorkspaceSymbols(rebuild: boolean = true, token?: vscode.CancellationToken): Promise[] { if (token && token.isCancellationRequested) { diff --git a/src/test/install/pythonInstallation.test.ts b/src/test/install/pythonInstallation.test.ts new file mode 100644 index 000000000000..412e19ae3723 --- /dev/null +++ b/src/test/install/pythonInstallation.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +import * as assert from 'assert'; +import { ChildProcess, SpawnOptions } from 'child_process'; +import { Container } from 'inversify'; +import * as Rx from 'rxjs'; +import * as TypeMoq from 'typemoq'; +import * as vscode from 'vscode'; +import { IApplicationShell } from '../../client/common/application/types'; +import { IPythonSettings } from '../../client/common/configSettings'; +import { STANDARD_OUTPUT_CHANNEL } from '../../client/common/constants'; +import { PythonInstaller } from '../../client/common/installer/pythonInstallation'; +import { IFileSystem, IPlatformService } from '../../client/common/platform/types'; +import { IProcessService, ObservableExecutionResult, Output } from '../../client/common/process/types'; +import { IOutputChannel } from '../../client/common/types'; +import { IInterpreterLocatorService } from '../../client/interpreter/contracts'; +import { InterpreterType, PythonInterpreter } from '../../client/interpreter/contracts'; +import { ServiceContainer } from '../../client/ioc/container'; +import { ServiceManager } from '../../client/ioc/serviceManager'; +import { IServiceContainer } from '../../client/ioc/types'; +import { closeActiveWindows, initialize, initializeTest } from '../initialize'; + +class TestContext { + public serviceManager: ServiceManager; + public serviceContainer: IServiceContainer; + public platform: TypeMoq.IMock; + public fileSystem: TypeMoq.IMock; + public appShell: TypeMoq.IMock; + public locator: TypeMoq.IMock; + public settings: TypeMoq.IMock; + public process: TypeMoq.IMock; + public output: TypeMoq.IMock; + public pythonInstaller: PythonInstaller; + + constructor(isMac: boolean) { + const cont = new Container(); + this.serviceManager = new ServiceManager(cont); + this.serviceContainer = new ServiceContainer(cont); + + this.platform = TypeMoq.Mock.ofType(); + this.fileSystem = TypeMoq.Mock.ofType(); + this.appShell = TypeMoq.Mock.ofType(); + this.locator = TypeMoq.Mock.ofType(); + this.settings = TypeMoq.Mock.ofType(); + this.process = TypeMoq.Mock.ofType(); + this.output = TypeMoq.Mock.ofType(); + + this.serviceManager.addSingletonInstance(IPlatformService, this.platform.object); + this.serviceManager.addSingletonInstance(IFileSystem, this.fileSystem.object); + this.serviceManager.addSingletonInstance(IApplicationShell, this.appShell.object); + this.serviceManager.addSingletonInstance(IInterpreterLocatorService, this.locator.object); + this.serviceManager.addSingletonInstance(IProcessService, this.process.object); + this.serviceManager.addSingletonInstance(IOutputChannel, this.output.object, STANDARD_OUTPUT_CHANNEL); + this.pythonInstaller = new PythonInstaller(this.serviceContainer); + + this.platform.setup(x => x.isMac).returns(() => isMac); + this.platform.setup(x => x.isWindows).returns(() => !isMac); + } +} + +// tslint:disable-next-line:max-func-body-length +suite('Installation', () => { + suiteSetup(async () => { + await initialize(); + }); + setup(initializeTest); + suiteTeardown(closeActiveWindows); + teardown(closeActiveWindows); + + test('Disable checks', async () => { + const c = new TestContext(false); + let showErrorMessageCalled = false; + + c.settings.setup(s => s.disableInstallationChecks).returns(() => true); + c.appShell.setup(x => x.showErrorMessage(TypeMoq.It.isAnyString())).callback(() => showErrorMessageCalled = true); + const passed = await c.pythonInstaller.checkPythonInstallation(c.settings.object); + assert.equal(passed, true, 'Disabling checks has no effect'); + assert.equal(showErrorMessageCalled, false, 'Disabling checks has no effect'); + }); + + test('Windows: Python missing', async () => { + const c = new TestContext(false); + let showErrorMessageCalled = false; + let openUrlCalled = false; + let url; + + c.appShell.setup(x => x.showErrorMessage(TypeMoq.It.isAnyString())).callback(() => showErrorMessageCalled = true); + c.appShell.setup(x => x.openUrl(TypeMoq.It.isAnyString())).callback((s: string) => { + openUrlCalled = true; + url = s; + }); + c.locator.setup(x => x.getInterpreters()).returns(() => Promise.resolve([])); + + const passed = await c.pythonInstaller.checkPythonInstallation(c.settings.object); + assert.equal(passed, false, 'Python reported as present'); + assert.equal(showErrorMessageCalled, true, 'Error message not shown'); + assert.equal(openUrlCalled, true, 'Python download page not opened'); + assert.equal(url, 'https://www.python.org/downloads', 'Python download page is incorrect'); + }); + + test('Mac: Python missing', async () => { + const c = new TestContext(true); + let called = false; + c.appShell.setup(x => x.showWarningMessage(TypeMoq.It.isAnyString())).callback(() => called = true); + c.settings.setup(x => x.pythonPath).returns(() => 'python'); + const interpreter: PythonInterpreter = { + path: 'python', + type: InterpreterType.Unknown + }; + c.locator.setup(x => x.getInterpreters()).returns(() => Promise.resolve([interpreter])); + + const passed = await c.pythonInstaller.checkPythonInstallation(c.settings.object); + assert.equal(passed, true, 'Default MacOS Python not accepted'); + assert.equal(called, true, 'Warning not shown'); + }); + + test('Mac: Default Python, user refused install', async () => { + const c = new TestContext(true); + let errorMessage = ''; + + c.appShell + .setup(x => x.showErrorMessage(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString())) + .callback((m: string, a1: string, a2: string) => errorMessage = m) + .returns(() => Promise.resolve('No')); + c.locator.setup(x => x.getInterpreters()).returns(() => Promise.resolve([])); + + const passed = await c.pythonInstaller.checkPythonInstallation(c.settings.object); + assert.equal(passed, false, 'Default MacOS Python accepted'); + assert.equal(errorMessage.startsWith('Python that comes with MacOS is not supported'), true, 'Error message that MacOS Python not supported not shown'); + }); + + test('Mac: Default Python, Brew installation', async () => { + const c = new TestContext(true); + let errorMessage = ''; + let processName = ''; + let args; + let brewPath; + let outputShown = false; + + c.appShell + .setup(x => x.showErrorMessage(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString())) + .returns(() => Promise.resolve('Yes')); + c.appShell + .setup(x => x.showErrorMessage(TypeMoq.It.isAnyString())) + .callback((m: string) => errorMessage = m); + c.locator.setup(x => x.getInterpreters()).returns(() => Promise.resolve([])); + c.fileSystem + .setup(x => x.fileExistsAsync(TypeMoq.It.isAnyString())) + .returns((p: string) => { + brewPath = p; + return Promise.resolve(false); + }); + + const childProcess = TypeMoq.Mock.ofType(); + childProcess + .setup(p => p.on('exit', TypeMoq.It.isAny())) + .callback((e: string, listener: (code, signal) => void) => { + listener.call(0, undefined); + }); + const processOutput: Output = { + source: 'stdout', + out: 'started' + }; + const observable = new Rx.Observable>(subscriber => subscriber.next(processOutput)); + const brewInstallProcess: ObservableExecutionResult = { + proc: childProcess.object, + out: observable + }; + + c.output.setup(x => x.show()).callback(() => outputShown = true); + c.process + .setup(x => x.execObservable(TypeMoq.It.isAnyString(), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((p: string, a: string[], o: SpawnOptions) => { + processName = p; + args = a; + }) + .returns(() => brewInstallProcess); + + await c.pythonInstaller.checkPythonInstallation(c.settings.object); + + assert.notEqual(brewPath, undefined, 'Brew installer location not checked'); + assert.equal(brewPath, '/usr/local/bin/brew', 'Brew installer location is incorrect'); + assert.notEqual(processName, undefined, 'Brew installer not invoked'); + assert.equal(processName, '/usr/bin/ruby', 'Brew installer name is incorrect'); + assert.equal(args[0], '-e', 'Brew installer argument is incorrect'); + assert.equal(args[1], '"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"', 'Homebrew installer argument is incorrect'); + assert.equal(outputShown, true, 'Output panel not shown'); + assert.equal(errorMessage.startsWith('Unable to install Homebrew'), true, 'Homebrew install failed message no shown'); + + c.fileSystem + .setup(x => x.fileExistsAsync(TypeMoq.It.isAnyString())) + .returns(() => Promise.resolve(true)); + errorMessage = ''; + + await c.pythonInstaller.checkPythonInstallation(c.settings.object); + assert.equal(errorMessage, '', `Unexpected error message ${errorMessage}`); + assert.equal(processName, 'brew', 'Brew installer name is incorrect'); + assert.equal(args[0], 'install', 'Brew "install" argument is incorrect'); + assert.equal(args[1], 'python', 'Brew "python" argument is incorrect'); + }); +}); diff --git a/src/test/linters/lint.test.ts b/src/test/linters/lint.test.ts index 0b2c1714a7b1..4717a84a61f9 100644 --- a/src/test/linters/lint.test.ts +++ b/src/test/linters/lint.test.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { OutputChannel, Uri } from 'vscode'; import * as vscode from 'vscode'; import { STANDARD_OUTPUT_CHANNEL } from '../../client/common/constants'; -import { Product, SettingToDisableProduct } from '../../client/common/installer/installer'; +import { Installer, Product, SettingToDisableProduct } from '../../client/common/installer/installer'; import { IInstaller, ILogger, IOutputChannel } from '../../client/common/types'; import { IServiceContainer } from '../../client/ioc/types'; import { BaseLinter } from '../../client/linters/baseLinter';