diff --git a/package.json b/package.json index f73d650b1a3d..99c768a4878a 100644 --- a/package.json +++ b/package.json @@ -1086,6 +1086,19 @@ "description": "Path to folder with a list of Virtual Environments (e.g. ~/.pyenv, ~/Envs, ~/.virtualenvs).", "scope": "resource" }, + "python.venvFolders": { + "type": "array", + "default": [ + "envs", + ".pyenv", + ".direnv" + ], + "description": "Folders to look into for virtual environments.", + "scope": "resource", + "items": { + "type": "string" + } + }, "python.envFile": { "type": "string", "description": "Absolute path to a file containing environment variable definitions.", @@ -1860,4 +1873,4 @@ "publisherDisplayName": "Microsoft", "publisherId": "998b010b-e2af-44a5-a6cd-0b5fd3b9b6f8" } -} +} \ No newline at end of file diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 90da5554de81..1808e7c220aa 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -413,8 +413,6 @@ export interface IWorkspaceService { * ~~The folder that is open in the editor. `undefined` when no folder * has been opened.~~ * - * @deprecated Use [`workspaceFolders`](#workspace.workspaceFolders) instead. - * * @readonly */ readonly rootPath: string | undefined; diff --git a/src/client/common/application/workspace.ts b/src/client/common/application/workspace.ts index 1d56349dc9ff..7475ab77c94f 100644 --- a/src/client/common/application/workspace.ts +++ b/src/client/common/application/workspace.ts @@ -12,7 +12,7 @@ export class WorkspaceService implements IWorkspaceService { return vscode.workspace.onDidChangeConfiguration; } public get rootPath(): string | undefined { - return vscode.workspace.rootPath; + return Array.isArray(vscode.workspace.workspaceFolders) ? vscode.workspace.workspaceFolders[0].uri.fsPath : undefined; } public get workspaceFolders(): vscode.WorkspaceFolder[] | undefined { return vscode.workspace.workspaceFolders; diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index ef806ec2ee46..9da482ded147 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -32,6 +32,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings { public envFile: string; public disablePromptForFeatures: string[]; public venvPath: string; + public venvFolders: string[]; public devOptions: string[]; public linting: ILintingSettings; public formatting: IFormattingSettings; @@ -106,6 +107,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings { this.pythonPath = getAbsolutePath(this.pythonPath, workspaceRoot); // tslint:disable-next-line:no-backbone-get-set-outside-model no-non-null-assertion this.venvPath = systemVariables.resolveAny(pythonSettings.get('venvPath'))!; + this.venvFolders = systemVariables.resolveAny(pythonSettings.get('venvFolders'))!; // tslint:disable-next-line:no-backbone-get-set-outside-model no-non-null-assertion this.jediPath = systemVariables.resolveAny(pythonSettings.get('jediPath'))!; if (typeof this.jediPath === 'string' && this.jediPath.length > 0) { diff --git a/src/client/common/installer/channelManager.ts b/src/client/common/installer/channelManager.ts index 3ee00df21419..3e3747f3d412 100644 --- a/src/client/common/installer/channelManager.ts +++ b/src/client/common/installer/channelManager.ts @@ -41,9 +41,22 @@ export class InstallationChannelManager implements IInstallationChannelManager { } public async getInstallationChannels(resource?: Uri): Promise { - const installers = this.serviceContainer.getAll(IModuleInstaller); + let installers = this.serviceContainer.getAll(IModuleInstaller); const supportedInstallers: IModuleInstaller[] = []; + if (installers.length === 0) { + return []; + } + // group by priority and pick supported from the highest priority + installers = installers.sort((a, b) => b.priority - a.priority); + let currentPri = installers[0].priority; for (const mi of installers) { + if (mi.priority !== currentPri) { + if (supportedInstallers.length > 0) { + break; // return highest priority supported installers + } + // If none supported, try next priority group + currentPri = mi.priority; + } if (await mi.isSupported(resource)) { supportedInstallers.push(mi); } diff --git a/src/client/common/installer/condaInstaller.ts b/src/client/common/installer/condaInstaller.ts index 0c279d169510..a4802817b8d9 100644 --- a/src/client/common/installer/condaInstaller.ts +++ b/src/client/common/installer/condaInstaller.ts @@ -15,6 +15,9 @@ export class CondaInstaller extends ModuleInstaller implements IModuleInstaller public get displayName() { return 'Conda'; } + public get priority(): number { + return 0; + } constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { super(serviceContainer); } diff --git a/src/client/common/installer/pipEnvInstaller.ts b/src/client/common/installer/pipEnvInstaller.ts new file mode 100644 index 000000000000..cbde0ea3335d --- /dev/null +++ b/src/client/common/installer/pipEnvInstaller.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { Uri } from 'vscode'; +import { IInterpreterLocatorService, PIPENV_SERVICE } from '../../interpreter/contracts'; +import { IServiceContainer } from '../../ioc/types'; +import { ITerminalServiceFactory } from '../terminal/types'; +import { IModuleInstaller } from './types'; + +const pipenvName = 'pipenv'; + +@injectable() +export class PipEnvInstaller implements IModuleInstaller { + private readonly pipenv: IInterpreterLocatorService; + + public get displayName() { + return pipenvName; + } + public get priority(): number { + return 10; + } + + constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer) { + this.pipenv = this.serviceContainer.get(IInterpreterLocatorService, PIPENV_SERVICE); + } + + public installModule(name: string): Promise { + const terminalService = this.serviceContainer.get(ITerminalServiceFactory).getTerminalService(); + return terminalService.sendCommand(pipenvName, ['install', name]); + } + + public async isSupported(resource?: Uri): Promise { + const interpreters = await this.pipenv.getInterpreters(resource); + return interpreters && interpreters.length > 0; + } +} diff --git a/src/client/common/installer/pipInstaller.ts b/src/client/common/installer/pipInstaller.ts index 7e49e7afa217..bfc7ff4deadd 100644 --- a/src/client/common/installer/pipInstaller.ts +++ b/src/client/common/installer/pipInstaller.ts @@ -14,14 +14,17 @@ export class PipInstaller extends ModuleInstaller implements IModuleInstaller { public get displayName() { return 'Pip'; } - constructor( @inject(IServiceContainer) serviceContainer: IServiceContainer) { + public get priority(): number { + return 0; + } + constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { super(serviceContainer); } public isSupported(resource?: Uri): Promise { return this.isPipAvailable(resource); } protected async getExecutionInfo(moduleName: string, resource?: Uri): Promise { - const proxyArgs = []; + const proxyArgs: string[] = []; const proxy = workspace.getConfiguration('http').get('proxy', ''); if (proxy.length > 0) { proxyArgs.push('--proxy'); diff --git a/src/client/common/installer/serviceRegistry.ts b/src/client/common/installer/serviceRegistry.ts index 0282d033fd0a..9f50aa77fba3 100644 --- a/src/client/common/installer/serviceRegistry.ts +++ b/src/client/common/installer/serviceRegistry.ts @@ -5,11 +5,13 @@ import { IServiceManager } from '../../ioc/types'; import { InstallationChannelManager } from './channelManager'; import { CondaInstaller } from './condaInstaller'; +import { PipEnvInstaller } from './pipEnvInstaller'; import { PipInstaller } from './pipInstaller'; import { IInstallationChannelManager, IModuleInstaller } from './types'; export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IModuleInstaller, CondaInstaller); serviceManager.addSingleton(IModuleInstaller, PipInstaller); + serviceManager.addSingleton(IModuleInstaller, PipEnvInstaller); serviceManager.addSingleton(IInstallationChannelManager, InstallationChannelManager); } diff --git a/src/client/common/installer/types.ts b/src/client/common/installer/types.ts index 9ac61714511a..d84cf7bb64d2 100644 --- a/src/client/common/installer/types.ts +++ b/src/client/common/installer/types.ts @@ -7,6 +7,7 @@ import { Product } from '../types'; export const IModuleInstaller = Symbol('IModuleInstaller'); export interface IModuleInstaller { readonly displayName: string; + readonly priority: number; installModule(name: string, resource?: Uri): Promise; isSupported(resource?: Uri): Promise; } diff --git a/src/client/common/platform/platformService.ts b/src/client/common/platform/platformService.ts index f3684372ce19..f60f9eaf3ba5 100644 --- a/src/client/common/platform/platformService.ts +++ b/src/client/common/platform/platformService.ts @@ -9,8 +9,8 @@ import { IPlatformService } from './types'; @injectable() export class PlatformService implements IPlatformService { - private _isWindows: boolean; - private _isMac: boolean; + private _isWindows: boolean; + private _isMac: boolean; constructor() { this._isWindows = /^win/.test(process.platform); @@ -30,4 +30,8 @@ export class PlatformService implements IPlatformService { } public get pathVariableName() { return this.isWindows ? WINDOWS_PATH_VARIABLE_NAME : NON_WINDOWS_PATH_VARIABLE_NAME; - }} + } + public get virtualEnvBinName() { + return this.isWindows ? 'scripts' : 'bin'; + } +} diff --git a/src/client/common/platform/types.ts b/src/client/common/platform/types.ts index b78c8886f91c..d87722efdd38 100644 --- a/src/client/common/platform/types.ts +++ b/src/client/common/platform/types.ts @@ -25,6 +25,7 @@ export interface IPlatformService { isLinux: boolean; is64bit: boolean; pathVariableName: 'Path' | 'PATH'; + virtualEnvBinName: 'bin' | 'scripts'; } export const IFileSystem = Symbol('IFileSystem'); diff --git a/src/client/common/process/proc.ts b/src/client/common/process/proc.ts index 428f428719ac..5d8cefd935cb 100644 --- a/src/client/common/process/proc.ts +++ b/src/client/common/process/proc.ts @@ -11,7 +11,7 @@ import { ExecutionResult, IBufferDecoder, IProcessService, ObservableExecutionRe @injectable() export class ProcessService implements IProcessService { - constructor( @inject(IBufferDecoder) private decoder: IBufferDecoder) { } + constructor(@inject(IBufferDecoder) private decoder: IBufferDecoder) { } public execObservable(file: string, args: string[], options: SpawnOptions = {}): ObservableExecutionResult { const encoding = options.encoding = typeof options.encoding === 'string' && options.encoding.length > 0 ? options.encoding : DEFAULT_ENCODING; delete options.encoding; @@ -72,7 +72,7 @@ export class ProcessService implements IProcessService { return { proc, out: output }; } - public async exec(file: string, args: string[], options: SpawnOptions = {}): Promise> { + public exec(file: string, args: string[], options: SpawnOptions = {}): Promise> { const encoding = options.encoding = typeof options.encoding === 'string' && options.encoding.length > 0 ? options.encoding : DEFAULT_ENCODING; delete options.encoding; const spawnOptions = { ...options }; diff --git a/src/client/common/process/pythonProcess.ts b/src/client/common/process/pythonProcess.ts index a8a502f9a1c4..0d6622bed472 100644 --- a/src/client/common/process/pythonProcess.ts +++ b/src/client/common/process/pythonProcess.ts @@ -3,6 +3,7 @@ import { injectable } from 'inversify'; import { Uri } from 'vscode'; +import { IInterpreterVersionService } from '../../interpreter/contracts'; import { IServiceContainer } from '../../ioc/types'; import { ErrorUtils } from '../errors/errorUtils'; import { ModuleNotInstalledError } from '../errors/moduleNotInstalledError'; @@ -13,24 +14,24 @@ import { ExecutionResult, IProcessService, IPythonExecutionService, ObservableEx @injectable() export class PythonExecutionService implements IPythonExecutionService { - private procService: IProcessService; - private configService: IConfigurationService; - private fileSystem: IFileSystem; + private readonly procService: IProcessService; + private readonly configService: IConfigurationService; + private readonly fileSystem: IFileSystem; - constructor(serviceContainer: IServiceContainer, private envVars: EnvironmentVariables | undefined, private resource?: Uri) { + constructor(private serviceContainer: IServiceContainer, private envVars: EnvironmentVariables | undefined, private resource?: Uri) { this.procService = serviceContainer.get(IProcessService); this.configService = serviceContainer.get(IConfigurationService); this.fileSystem = serviceContainer.get(IFileSystem); } public async getVersion(): Promise { - return this.procService.exec(this.pythonPath, ['--version'], { env: this.envVars, mergeStdOutErr: true }) - .then(output => output.stdout.trim()); + const versionService = this.serviceContainer.get(IInterpreterVersionService); + return versionService.getVersion(this.pythonPath, ''); } public async getExecutablePath(): Promise { // If we've passed the python file, then return the file. // This is because on mac if using the interpreter /usr/bin/python2.7 we can get a different value for the path - if (await this.fileSystem.fileExistsAsync(this.pythonPath)){ + if (await this.fileSystem.fileExistsAsync(this.pythonPath)) { return this.pythonPath; } return this.procService.exec(this.pythonPath, ['-c', 'import sys;print(sys.executable)'], { env: this.envVars, throwOnStdErr: true }) diff --git a/src/client/common/process/types.ts b/src/client/common/process/types.ts index 754cd297dcf9..c4a79d6435a6 100644 --- a/src/client/common/process/types.ts +++ b/src/client/common/process/types.ts @@ -50,7 +50,6 @@ export interface IPythonExecutionFactory { export const IPythonExecutionService = Symbol('IPythonExecutionService'); export interface IPythonExecutionService { - getVersion(): Promise; getExecutablePath(): Promise; isModuleInstalled(moduleName: string): Promise; diff --git a/src/client/common/types.ts b/src/client/common/types.ts index fd81483e2ffe..09d637670772 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import {Socket} from 'net'; +import { Socket } from 'net'; import { ConfigurationTarget, DiagnosticSeverity, Disposable, Uri } from 'vscode'; import { EnvironmentVariables } from './variables/types'; @@ -96,6 +96,7 @@ export interface ICurrentProcess { export interface IPythonSettings { readonly pythonPath: string; readonly venvPath: string; + readonly venvFolders: string[]; readonly jediPath: string; readonly jediMemoryLimit: number; readonly devOptions: string[]; diff --git a/src/client/interpreter/contracts.ts b/src/client/interpreter/contracts.ts index 040c910c7373..b71c77eb5ccd 100644 --- a/src/client/interpreter/contracts.ts +++ b/src/client/interpreter/contracts.ts @@ -9,6 +9,7 @@ export const CURRENT_PATH_SERVICE = 'CurrentPathService'; export const KNOWN_PATH_SERVICE = 'KnownPathsService'; export const GLOBAL_VIRTUAL_ENV_SERVICE = 'VirtualEnvService'; export const WORKSPACE_VIRTUAL_ENV_SERVICE = 'WorkspaceVirtualEnvService'; +export const PIPENV_SERVICE = 'PipEnvService'; export const IInterpreterVersionService = Symbol('IInterpreterVersionService'); export interface IInterpreterVersionService { diff --git a/src/client/interpreter/index.ts b/src/client/interpreter/interpreterService.ts similarity index 77% rename from src/client/interpreter/index.ts rename to src/client/interpreter/interpreterService.ts index 485766343d3d..361689fcc635 100644 --- a/src/client/interpreter/index.ts +++ b/src/client/interpreter/interpreterService.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { ConfigurationTarget, Disposable, Event, EventEmitter, Uri } from 'vscode'; import { IDocumentManager, IWorkspaceService } from '../common/application/types'; import { PythonSettings } from '../common/configSettings'; +import { IFileSystem } from '../common/platform/types'; import { IPythonExecutionFactory } from '../common/process/types'; import { IConfigurationService, IDisposableRegistry } from '../common/types'; import * as utils from '../common/utils'; @@ -11,21 +12,22 @@ import { IPythonPathUpdaterServiceManager } from './configuration/types'; import { IInterpreterDisplay, IInterpreterHelper, IInterpreterLocatorService, IInterpreterService, IInterpreterVersionService, INTERPRETER_LOCATOR_SERVICE, - InterpreterType, PythonInterpreter, WORKSPACE_VIRTUAL_ENV_SERVICE + InterpreterType, PIPENV_SERVICE, PythonInterpreter, WORKSPACE_VIRTUAL_ENV_SERVICE } from './contracts'; import { IVirtualEnvironmentManager } from './virtualEnvs/types'; @injectable() -export class InterpreterManager implements Disposable, IInterpreterService { - private readonly interpreterProvider: IInterpreterLocatorService; +export class InterpreterService implements Disposable, IInterpreterService { + private readonly locator: IInterpreterLocatorService; private readonly pythonPathUpdaterService: IPythonPathUpdaterServiceManager; private readonly helper: IInterpreterHelper; + private readonly fs: IFileSystem; private readonly didChangeInterpreterEmitter = new EventEmitter(); constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer) { - this.interpreterProvider = serviceContainer.get(IInterpreterLocatorService, INTERPRETER_LOCATOR_SERVICE); + this.locator = serviceContainer.get(IInterpreterLocatorService, INTERPRETER_LOCATOR_SERVICE); this.helper = serviceContainer.get(IInterpreterHelper); - + this.fs = this.serviceContainer.get(IFileSystem); this.pythonPathUpdaterService = this.serviceContainer.get(IPythonPathUpdaterServiceManager); } @@ -42,32 +44,39 @@ export class InterpreterManager implements Disposable, IInterpreterService { (configService.getSettings() as PythonSettings).addListener('change', this.onConfigChanged); } - public getInterpreters(resource?: Uri) { - return this.interpreterProvider.getInterpreters(resource); + public getInterpreters(resource?: Uri): Promise { + return this.locator.getInterpreters(resource); } - public async autoSetInterpreter() { - if (!this.shouldAutoSetInterpreter()) { + public async autoSetInterpreter(): Promise { + if (!await this.shouldAutoSetInterpreter()) { return; } const activeWorkspace = this.helper.getActiveWorkspaceUri(); if (!activeWorkspace) { return; } + // Check pipenv first + const pipenvService = this.serviceContainer.get(IInterpreterLocatorService, PIPENV_SERVICE); + let interpreters = await pipenvService.getInterpreters(activeWorkspace.folderUri); + if (interpreters.length > 0) { + await this.pythonPathUpdaterService.updatePythonPath(interpreters[0].path, activeWorkspace.configTarget, 'load', activeWorkspace.folderUri); + return; + } + // Now check virtual environments under the workspace root const virtualEnvInterpreterProvider = this.serviceContainer.get(IInterpreterLocatorService, WORKSPACE_VIRTUAL_ENV_SERVICE); - const interpreters = await virtualEnvInterpreterProvider.getInterpreters(activeWorkspace.folderUri); + interpreters = await virtualEnvInterpreterProvider.getInterpreters(activeWorkspace.folderUri); const workspacePathUpper = activeWorkspace.folderUri.fsPath.toUpperCase(); const interpretersInWorkspace = interpreters.filter(interpreter => interpreter.path.toUpperCase().startsWith(workspacePathUpper)); if (interpretersInWorkspace.length === 0) { return; } - // Always pick the highest version by default. + const pythonPath = interpretersInWorkspace.sort((a, b) => a.version! > b.version! ? 1 : -1)[0].path; // 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.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); @@ -75,7 +84,7 @@ export class InterpreterManager implements Disposable, IInterpreterService { } public dispose(): void { - this.interpreterProvider.dispose(); + this.locator.dispose(); const configService = this.serviceContainer.get(IConfigurationService); (configService.getSettings() as PythonSettings).removeListener('change', this.onConfigChanged); this.didChangeInterpreterEmitter.dispose(); @@ -112,7 +121,7 @@ export class InterpreterManager implements Disposable, IInterpreterService { version: versionInfo }; } - private shouldAutoSetInterpreter() { + private async shouldAutoSetInterpreter(): Promise { const activeWorkspace = this.helper.getActiveWorkspaceUri(); if (!activeWorkspace) { return false; @@ -125,13 +134,21 @@ export class InterpreterManager implements Disposable, IInterpreterService { return false; } if (activeWorkspace.configTarget === ConfigurationTarget.Workspace) { - return pythonPathInConfig!.workspaceValue === undefined || pythonPathInConfig!.workspaceValue === 'python'; + return !await this.isPythonPathDefined(pythonPathInConfig!.workspaceValue); } if (activeWorkspace.configTarget === ConfigurationTarget.WorkspaceFolder) { - return pythonPathInConfig!.workspaceFolderValue === undefined || pythonPathInConfig!.workspaceFolderValue === 'python'; + return !await this.isPythonPathDefined(pythonPathInConfig!.workspaceFolderValue); } return false; } + + private async isPythonPathDefined(pythonPath: string | undefined): Promise { + if (pythonPath === undefined || pythonPath === 'python') { + return false; + } + return await this.fs.directoryExistsAsync(pythonPath); + } + private onConfigChanged = () => { this.didChangeInterpreterEmitter.fire(); const interpreterDisplay = this.serviceContainer.get(IInterpreterDisplay); diff --git a/src/client/interpreter/interpreterVersion.ts b/src/client/interpreter/interpreterVersion.ts index dfb7ce7ec494..5235d60f52ce 100644 --- a/src/client/interpreter/interpreterVersion.ts +++ b/src/client/interpreter/interpreterVersion.ts @@ -3,11 +3,11 @@ import '../common/extensions'; import { IProcessService } from '../common/process/types'; import { IInterpreterVersionService } from './contracts'; -const PIP_VERSION_REGEX = '\\d\\.\\d(\\.\\d)+'; +export const PIP_VERSION_REGEX = '\\d+\\.\\d+(\\.\\d+)'; @injectable() export class InterpreterVersionService implements IInterpreterVersionService { - constructor( @inject(IProcessService) private processService: IProcessService) { } + constructor(@inject(IProcessService) private processService: IProcessService) { } public async getVersion(pythonPath: string, defaultValue: string): Promise { return this.processService.exec(pythonPath, ['--version'], { mergeStdOutErr: true }) .then(output => output.stdout.splitLines()[0]) diff --git a/src/client/interpreter/locators/index.ts b/src/client/interpreter/locators/index.ts index c48040036777..f40667b70b44 100644 --- a/src/client/interpreter/locators/index.ts +++ b/src/client/interpreter/locators/index.ts @@ -14,6 +14,7 @@ import { IInterpreterLocatorService, InterpreterType, KNOWN_PATH_SERVICE, + PIPENV_SERVICE, PythonInterpreter, WINDOWS_REGISTRY_SERVICE, WORKSPACE_VIRTUAL_ENV_SERVICE @@ -29,13 +30,19 @@ export class PythonInterpreterLocatorService implements IInterpreterLocatorServi serviceContainer.get(IDisposableRegistry).push(this); this.platform = serviceContainer.get(IPlatformService); } - public async getInterpreters(resource?: Uri) { + public async getInterpreters(resource?: Uri): Promise { + // Pipenv always wins + const pipenv = this.serviceContainer.get(IInterpreterLocatorService, PIPENV_SERVICE); + const interpreters = await pipenv.getInterpreters(resource); + if (interpreters.length > 0) { + return interpreters; + } return this.getInterpretersPerResource(resource); } public dispose() { this.disposables.forEach(disposable => disposable.dispose()); } - private async getInterpretersPerResource(resource?: Uri) { + private async getInterpretersPerResource(resource?: Uri): Promise { const locators = this.getLocators(); const promises = locators.map(async provider => provider.getInterpreters(resource)); const listOfInterpreters = await Promise.all(promises); @@ -60,7 +67,7 @@ export class PythonInterpreterLocatorService implements IInterpreterLocatorServi return accumulator; }, []); } - private getLocators() { + private getLocators(): IInterpreterLocatorService[] { const locators: IInterpreterLocatorService[] = []; // The order of the services is important. if (this.platform.isWindows) { diff --git a/src/client/interpreter/locators/services/baseVirtualEnvService.ts b/src/client/interpreter/locators/services/baseVirtualEnvService.ts index fe19dd221f8d..06b57b939436 100644 --- a/src/client/interpreter/locators/services/baseVirtualEnvService.ts +++ b/src/client/interpreter/locators/services/baseVirtualEnvService.ts @@ -47,8 +47,8 @@ export class BaseVirtualEnvService extends CacheableLocatorService { }); } private getProspectiveDirectoriesForLookup(subDirs: string[]) { - const isWindows = this.serviceContainer.get(IPlatformService).isWindows; - const dirToLookFor = isWindows ? 'SCRIPTS' : 'bin'; + const platform = this.serviceContainer.get(IPlatformService); + const dirToLookFor = platform.virtualEnvBinName; return subDirs.map(subDir => this.fileSystem.getSubDirectoriesAsync(subDir) .then(dirs => { diff --git a/src/client/interpreter/locators/services/globalVirtualEnvService.ts b/src/client/interpreter/locators/services/globalVirtualEnvService.ts index c05e5647f1ab..8f3160386dc8 100644 --- a/src/client/interpreter/locators/services/globalVirtualEnvService.ts +++ b/src/client/interpreter/locators/services/globalVirtualEnvService.ts @@ -7,7 +7,7 @@ import { inject, injectable, named } from 'inversify'; import * as os from 'os'; import * as path from 'path'; import { Uri } from 'vscode'; -import { ICurrentProcess } from '../../../common/types'; +import { IConfigurationService, ICurrentProcess } from '../../../common/types'; import { IServiceContainer } from '../../../ioc/types'; import { IVirtualEnvironmentsSearchPathProvider } from '../../contracts'; import { BaseVirtualEnvService } from './baseVirtualEnvService'; @@ -24,21 +24,29 @@ export class GlobalVirtualEnvService extends BaseVirtualEnvService { @injectable() export class GlobalVirtualEnvironmentsSearchPathProvider implements IVirtualEnvironmentsSearchPathProvider { private readonly process: ICurrentProcess; + private readonly config: IConfigurationService; constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { this.process = serviceContainer.get(ICurrentProcess); + this.config = serviceContainer.get(IConfigurationService); } public getSearchPaths(_resource?: Uri): string[] { const homedir = os.homedir(); - const folders = ['Envs', '.virtualenvs'].map(item => path.join(homedir, item)); + const venvFolders = this.config.getSettings(_resource).venvFolders; + const folders = venvFolders.map(item => path.join(homedir, item)); // tslint:disable-next-line:no-string-literal - let pyenvRoot = this.process.env['PYENV_ROOT']; - pyenvRoot = pyenvRoot ? pyenvRoot : path.join(homedir, '.pyenv'); - - folders.push(pyenvRoot); - folders.push(path.join(pyenvRoot, 'versions')); + const pyenvRoot = this.process.env['PYENV_ROOT']; + if (pyenvRoot) { + folders.push(pyenvRoot); + folders.push(path.join(pyenvRoot, 'versions')); + } else { + const pyenvVersions = path.join('.pyenv', 'versions'); + if (venvFolders.indexOf('.pyenv') >= 0 && venvFolders.indexOf(pyenvVersions) < 0) { + folders.push(pyenvVersions); + } + } return folders; } } diff --git a/src/client/interpreter/locators/services/pipEnvService.ts b/src/client/interpreter/locators/services/pipEnvService.ts new file mode 100644 index 000000000000..dea1f040404b --- /dev/null +++ b/src/client/interpreter/locators/services/pipEnvService.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { Uri } from 'vscode'; +import { IApplicationShell, IWorkspaceService } from '../../../common/application/types'; +import { createDeferred, Deferred } from '../../../common/helpers'; +import { IFileSystem } from '../../../common/platform/types'; +import { IProcessService } from '../../../common/process/types'; +import { getPythonExecutable } from '../../../debugger/Common/Utils'; +import { IServiceContainer } from '../../../ioc/types'; +import { IInterpreterLocatorService, IInterpreterVersionService, InterpreterType, PythonInterpreter } from '../../contracts'; + +const execName = 'pipenv'; +const CACHE_TIMEOUT = 2000; + +@injectable() +export class PipEnvService implements IInterpreterLocatorService { + private readonly versionService: IInterpreterVersionService; + private readonly process: IProcessService; + private readonly workspace: IWorkspaceService; + private readonly fs: IFileSystem; + + private pendingPromises: Deferred[] = []; + private readonly cachedInterpreters = new Map(); + + constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer) { + this.versionService = this.serviceContainer.get(IInterpreterVersionService); + this.process = this.serviceContainer.get(IProcessService); + this.workspace = this.serviceContainer.get(IWorkspaceService); + this.fs = this.serviceContainer.get(IFileSystem); + } + + public getInterpreters(resource?: Uri): Promise { + const pipenvCwd = this.getPipenvWorkingDirectory(resource); + if (!pipenvCwd) { + return Promise.resolve([]); + } + + // Try cache first + const interpreter = this.cachedInterpreters[pipenvCwd]; + if (interpreter) { + return Promise.resolve([interpreter]); + } + // We don't want multiple requests executing pipenv + const deferred = createDeferred(); + this.pendingPromises.push(deferred); + if (this.pendingPromises.length === 1) { + // First call, start worker + this.getInterpreter(pipenvCwd) + .then(x => this.resolveDeferred(x ? [x] : [])) + .catch(e => this.resolveDeferred([])); + } + return deferred.promise; + } + + public dispose() { + this.resolveDeferred([]); + } + + private resolveDeferred(result: PythonInterpreter[]) { + this.pendingPromises.forEach(p => p.resolve(result)); + this.pendingPromises = []; + } + + private async getInterpreter(pipenvCwd: string): Promise { + const interpreter = await this.getInterpreterFromPipenv(pipenvCwd); + if (interpreter) { + this.cachedInterpreters[pipenvCwd] = interpreter; + setTimeout(() => this.cachedInterpreters.clear(), CACHE_TIMEOUT); + } + return interpreter; + } + + private async getInterpreterFromPipenv(pipenvCwd: string): Promise { + const interpreterPath = await this.getInterpreterPathFromPipenv(pipenvCwd); + if (!interpreterPath) { + return; + } + const pythonExecutablePath = getPythonExecutable(interpreterPath); + const ver = await this.versionService.getVersion(pythonExecutablePath, ''); + return { + path: pythonExecutablePath, + displayName: `${ver} (${execName})`, + type: InterpreterType.VirtualEnv, + version: ver + }; + } + + private getPipenvWorkingDirectory(resource?: Uri): string | undefined { + // The file is not in a workspace. However, workspace may be opened + // and file is just a random file opened from elsewhere. In this case + // we still want to provide interpreter associated with the workspace. + // Otherwise if user tries and formats the file, we may end up using + // plain pip module installer to bring in the formatter and it is wrong. + const wsFolder = resource ? this.workspace.getWorkspaceFolder(resource) : undefined; + return wsFolder ? wsFolder.uri.fsPath : this.workspace.rootPath; + } + + private async getInterpreterPathFromPipenv(cwd: string): Promise { + // Quick check before actually running pipenv + if (!await this.fs.fileExistsAsync(path.join(cwd, 'pipfile'))) { + return; + } + const venvFolder = await this.invokePipenv('--venv', cwd); + return venvFolder && await this.fs.directoryExistsAsync(venvFolder) ? venvFolder : undefined; + } + + private async invokePipenv(arg: string, rootPath: string): Promise { + try { + const result = await this.process.exec(execName, [arg], { cwd: rootPath }); + if (result && result.stdout) { + return result.stdout.trim(); + } + // tslint:disable-next-line:no-empty + } catch (error) { + const appShell = this.serviceContainer.get(IApplicationShell); + appShell.showWarningMessage(`Workspace contains pipfile but attempt to run 'pipenv --venv' failed with ${error}. Make sure pipenv is on the PATH.`); + } + } +} diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index 56e60c0b6b35..18b891608f26 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -23,13 +23,14 @@ import { IShebangCodeLensProvider, IVirtualEnvironmentsSearchPathProvider, KNOWN_PATH_SERVICE, + PIPENV_SERVICE, WINDOWS_REGISTRY_SERVICE, WORKSPACE_VIRTUAL_ENV_SERVICE } from './contracts'; import { InterpreterDisplay } from './display'; import { ShebangCodeLensProvider } from './display/shebangCodeLensProvider'; import { InterpreterHelper } from './helpers'; -import { InterpreterManager } from './index'; +import { InterpreterService } from './interpreterService'; import { InterpreterVersionService } from './interpreterVersion'; import { PythonInterpreterLocatorService } from './locators/index'; import { CondaEnvFileService } from './locators/services/condaEnvFileService'; @@ -38,6 +39,7 @@ import { CondaService } from './locators/services/condaService'; import { CurrentPathService } from './locators/services/currentPathService'; import { GlobalVirtualEnvironmentsSearchPathProvider, GlobalVirtualEnvService } from './locators/services/globalVirtualEnvService'; import { getKnownSearchPathsForInterpreters, KnownPathsService } from './locators/services/KnownPathsService'; +import { PipEnvService } from './locators/services/pipEnvService'; import { WindowsRegistryService } from './locators/services/windowsRegistryService'; import { WorkspaceVirtualEnvironmentsSearchPathProvider, WorkspaceVirtualEnvService } from './locators/services/workspaceVirtualEnvService'; import { VirtualEnvironmentManager } from './virtualEnvs/index'; @@ -58,6 +60,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IInterpreterLocatorService, CurrentPathService, CURRENT_PATH_SERVICE); serviceManager.addSingleton(IInterpreterLocatorService, GlobalVirtualEnvService, GLOBAL_VIRTUAL_ENV_SERVICE); serviceManager.addSingleton(IInterpreterLocatorService, WorkspaceVirtualEnvService, WORKSPACE_VIRTUAL_ENV_SERVICE); + serviceManager.addSingleton(IInterpreterLocatorService, PipEnvService, PIPENV_SERVICE); const isWindows = serviceManager.get(IsWindows); if (isWindows) { @@ -65,7 +68,7 @@ export function registerTypes(serviceManager: IServiceManager) { } else { serviceManager.addSingleton(IInterpreterLocatorService, KnownPathsService, KNOWN_PATH_SERVICE); } - serviceManager.addSingleton(IInterpreterService, InterpreterManager); + serviceManager.addSingleton(IInterpreterService, InterpreterService); serviceManager.addSingleton(IInterpreterDisplay, InterpreterDisplay); serviceManager.addSingleton(IPythonPathUpdaterServiceFactory, PythonPathUpdaterServiceFactory); diff --git a/src/test/common/moduleInstaller.test.ts b/src/test/common/moduleInstaller.test.ts index 1cafd0593675..91c025d8c49a 100644 --- a/src/test/common/moduleInstaller.test.ts +++ b/src/test/common/moduleInstaller.test.ts @@ -6,6 +6,7 @@ import { PythonSettings } from '../../client/common/configSettings'; import { ConfigurationService } from '../../client/common/configuration/service'; import { CondaInstaller } from '../../client/common/installer/condaInstaller'; import { Installer } from '../../client/common/installer/installer'; +import { PipEnvInstaller } from '../../client/common/installer/pipEnvInstaller'; import { PipInstaller } from '../../client/common/installer/pipInstaller'; import { IModuleInstaller } from '../../client/common/installer/types'; import { Logger } from '../../client/common/logger'; @@ -18,7 +19,7 @@ import { CurrentProcess } from '../../client/common/process/currentProcess'; import { IProcessService, IPythonExecutionFactory } from '../../client/common/process/types'; import { ITerminalService, ITerminalServiceFactory } from '../../client/common/terminal/types'; import { IConfigurationService, ICurrentProcess, IInstaller, ILogger, IPathUtils, IPersistentStateFactory, IPythonSettings, IsWindows } from '../../client/common/types'; -import { ICondaService, IInterpreterLocatorService, IInterpreterService, INTERPRETER_LOCATOR_SERVICE, InterpreterType } from '../../client/interpreter/contracts'; +import { ICondaService, IInterpreterLocatorService, IInterpreterService, INTERPRETER_LOCATOR_SERVICE, InterpreterType, PIPENV_SERVICE } from '../../client/interpreter/contracts'; import { IServiceContainer } from '../../client/ioc/types'; import { rootWorkspaceUri } from '../common'; import { MockModuleInstaller } from '../mocks/moduleInstaller'; @@ -67,6 +68,7 @@ suite('Module Installer', () => { ioc.serviceManager.addSingleton(IModuleInstaller, PipInstaller); ioc.serviceManager.addSingleton(IModuleInstaller, CondaInstaller); + ioc.serviceManager.addSingleton(IModuleInstaller, PipEnvInstaller); condaService = TypeMoq.Mock.ofType(); ioc.serviceManager.addSingletonInstance(ICondaService, condaService.object); interpreterService = TypeMoq.Mock.ofType(); @@ -98,6 +100,7 @@ suite('Module Installer', () => { const mockInterpreterLocator = TypeMoq.Mock.ofType(); mockInterpreterLocator.setup(p => p.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([])); ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, mockInterpreterLocator.object, INTERPRETER_LOCATOR_SERVICE); + ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, TypeMoq.Mock.ofType().object, PIPENV_SERVICE); const processService = ioc.serviceContainer.get(IProcessService); processService.onExec((file, args, options, callback) => { @@ -109,7 +112,7 @@ suite('Module Installer', () => { } }); const moduleInstallers = ioc.serviceContainer.getAll(IModuleInstaller); - expect(moduleInstallers).length(3, 'Incorrect number of installers'); + expect(moduleInstallers).length(4, 'Incorrect number of installers'); const pipInstaller = moduleInstallers.find(item => item.displayName === 'Pip')!; expect(pipInstaller).not.to.be.an('undefined', 'Pip installer not found'); @@ -130,6 +133,7 @@ suite('Module Installer', () => { const mockInterpreterLocator = TypeMoq.Mock.ofType(); mockInterpreterLocator.setup(p => p.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([{ architecture: Architecture.Unknown, companyDisplayName: '', displayName: '', envName: '', path: pythonPath, type: InterpreterType.Conda, version: '' }])); ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, mockInterpreterLocator.object, INTERPRETER_LOCATOR_SERVICE); + ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, TypeMoq.Mock.ofType().object, PIPENV_SERVICE); const processService = ioc.serviceContainer.get(IProcessService); processService.onExec((file, args, options, callback) => { @@ -141,7 +145,7 @@ suite('Module Installer', () => { } }); const moduleInstallers = ioc.serviceContainer.getAll(IModuleInstaller); - expect(moduleInstallers).length(3, 'Incorrect number of installers'); + expect(moduleInstallers).length(4, 'Incorrect number of installers'); const pipInstaller = moduleInstallers.find(item => item.displayName === 'Pip')!; expect(pipInstaller).not.to.be.an('undefined', 'Pip installer not found'); @@ -169,6 +173,7 @@ suite('Module Installer', () => { const mockInterpreterLocator = TypeMoq.Mock.ofType(); mockInterpreterLocator.setup(p => p.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([{ path: interpreterPath, type: InterpreterType.Unknown }])); ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, mockInterpreterLocator.object, INTERPRETER_LOCATOR_SERVICE); + ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, TypeMoq.Mock.ofType().object, PIPENV_SERVICE); const moduleName = 'xyz'; @@ -191,6 +196,7 @@ suite('Module Installer', () => { const mockInterpreterLocator = TypeMoq.Mock.ofType(); mockInterpreterLocator.setup(p => p.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([{ path: interpreterPath, type: InterpreterType.Conda }])); ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, mockInterpreterLocator.object, INTERPRETER_LOCATOR_SERVICE); + ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, TypeMoq.Mock.ofType().object, PIPENV_SERVICE); const moduleName = 'xyz'; @@ -208,4 +214,31 @@ suite('Module Installer', () => { expect(argsSent.join(' ')).equal(`-m pip install -U ${moduleName}`, 'Invalid command sent to terminal for installation.'); }); + + test('Validate pipenv install arguments', async () => { + const mockInterpreterLocator = TypeMoq.Mock.ofType(); + mockInterpreterLocator.setup(p => p.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([{ path: 'interpreterPath', type: InterpreterType.VirtualEnv }])); + ioc.serviceManager.addSingletonInstance(IInterpreterLocatorService, mockInterpreterLocator.object, PIPENV_SERVICE); + + const moduleName = 'xyz'; + const moduleInstallers = ioc.serviceContainer.getAll(IModuleInstaller); + const pipInstaller = moduleInstallers.find(item => item.displayName === 'pipenv')!; + + expect(pipInstaller).not.to.be.an('undefined', 'pipenv installer not found'); + + let argsSent: string[] = []; + let command: string | undefined; + mockTerminalService + .setup(t => t.sendCommand(TypeMoq.It.isAnyString(), TypeMoq.It.isAny())) + .returns((cmd: string, args: string[]) => { + argsSent = args; + command = cmd; + return Promise.resolve(void 0); + }); + + await pipInstaller.installModule(moduleName); + + expect(command!).equal('pipenv', 'Invalid command sent to terminal for installation.'); + expect(argsSent.join(' ')).equal(`install ${moduleName}`, 'Invalid command arguments sent to terminal for installation.'); + }); }); diff --git a/src/test/common/process/execFactory.test.ts b/src/test/common/process/execFactory.test.ts index 3d0b2404a076..9a8aa3ce4322 100644 --- a/src/test/common/process/execFactory.test.ts +++ b/src/test/common/process/execFactory.test.ts @@ -5,10 +5,10 @@ import { expect } from 'chai'; import * as TypeMoq from 'typemoq'; import { Uri } from 'vscode'; import { IFileSystem } from '../../../client/common/platform/types'; -import { PythonExecutionFactory } from '../../../client/common/process/pythonExecutionFactory'; import { IProcessService } from '../../../client/common/process/types'; import { IConfigurationService, IPythonSettings } from '../../../client/common/types'; import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types'; +import { InterpreterVersionService } from '../../../client/interpreter/interpreterVersion'; import { IServiceContainer } from '../../../client/ioc/types'; // tslint:disable-next-line:max-func-body-length @@ -38,8 +38,8 @@ suite('PythonExecutableService', () => { configService.setup(c => c.getSettings(TypeMoq.It.isValue(undefined))).returns(() => pythonSettings.object); procService.setup(p => p.exec(TypeMoq.It.isValue(pythonPath), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ stdout: pythonVersion })); - const factory = await new PythonExecutionFactory(serviceContainer.object).create(); - const version = await factory.getVersion(); + const versionService = new InterpreterVersionService(procService.object); + const version = await versionService.getVersion(pythonPath, ''); expect(version).to.be.equal(pythonVersion); }); @@ -52,8 +52,8 @@ suite('PythonExecutableService', () => { configService.setup(c => c.getSettings(TypeMoq.It.isValue(resource))).returns(() => pythonSettings.object); procService.setup(p => p.exec(TypeMoq.It.isValue(pythonPath), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ stdout: pythonVersion })); - const factory = await new PythonExecutionFactory(serviceContainer.object).create(resource); - const version = await factory.getVersion(); + const versionService = new InterpreterVersionService(procService.object); + const version = await versionService.getVersion(pythonPath, ''); expect(version).to.be.equal(pythonVersion); }); diff --git a/src/test/common/process/pythonProc.simple.multiroot.test.ts b/src/test/common/process/pythonProc.simple.multiroot.test.ts index 1cbabdb6736f..2b9a3b513ad7 100644 --- a/src/test/common/process/pythonProc.simple.multiroot.test.ts +++ b/src/test/common/process/pythonProc.simple.multiroot.test.ts @@ -113,19 +113,6 @@ suite('PythonExecutableService', () => { await expect(randomModuleIsInstalled).to.eventually.equal(false, `Random module '${randomModuleName}' is installed`); }); - test('Value for \'python --version\' should be returned as version information', async () => { - const pythonPath = PythonSettings.getInstance(workspace4Path).pythonPath; - const expectedVersion = await new Promise(resolve => { - execFile(pythonPath, ['--version'], (error, stdout, stdErr) => { - const out = (typeof stdErr === 'string' ? stdErr : '') + EOL + (typeof stdout === 'string' ? stdout : ''); - resolve(out.trim()); - }); - }); - const pythonExecService = await pythonExecFactory.create(workspace4PyFile); - const version = await pythonExecService.getVersion(); - expect(version).to.equal(expectedVersion, 'Versions are not the same'); - }); - test('Ensure correct path to executable is returned', async () => { const pythonPath = PythonSettings.getInstance(workspace4Path).pythonPath; const expectedExecutablePath = await new Promise(resolve => { diff --git a/src/test/install/channelManager.channels.test.ts b/src/test/install/channelManager.channels.test.ts index 4c238aa20b43..714f9c65a8f8 100644 --- a/src/test/install/channelManager.channels.test.ts +++ b/src/test/install/channelManager.channels.test.ts @@ -9,6 +9,7 @@ import { IApplicationShell } from '../../client/common/application/types'; import { InstallationChannelManager } from '../../client/common/installer/channelManager'; import { IModuleInstaller } from '../../client/common/installer/types'; import { Product } from '../../client/common/types'; +import { IInterpreterLocatorService, InterpreterType, PIPENV_SERVICE, PythonInterpreter } from '../../client/interpreter/contracts'; import { ServiceContainer } from '../../client/ioc/container'; import { ServiceManager } from '../../client/ioc/serviceManager'; import { IServiceContainer } from '../../client/ioc/types'; @@ -17,11 +18,14 @@ import { IServiceContainer } from '../../client/ioc/types'; suite('Installation - installation channels', () => { let serviceManager: ServiceManager; let serviceContainer: IServiceContainer; + let pipEnv: TypeMoq.IMock; setup(() => { const cont = new Container(); serviceManager = new ServiceManager(cont); serviceContainer = new ServiceContainer(cont); + pipEnv = TypeMoq.Mock.ofType(); + serviceManager.addSingletonInstance(IInterpreterLocatorService, pipEnv.object, PIPENV_SERVICE); }); test('Single channel', async () => { @@ -44,6 +48,24 @@ suite('Installation - installation channels', () => { assert.equal(channels[1], installer3.object, 'Incorrect installer 2'); }); + test('pipenv channel', async () => { + mockInstaller(true, '1'); + mockInstaller(false, '2'); + mockInstaller(true, '3'); + const pipenvInstaller = mockInstaller(true, 'pipenv', 10); + + const interpreter: PythonInterpreter = { + path: 'pipenv', + type: InterpreterType.VirtualEnv + }; + pipEnv.setup(x => x.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve([interpreter])); + + const cm = new InstallationChannelManager(serviceContainer); + const channels = await cm.getInstallationChannels(); + assert.equal(channels.length, 1, 'Incorrect number of channels'); + assert.equal(channels[0], pipenvInstaller.object, 'Installer must be pipenv'); + }); + test('Select installer', async () => { const installer1 = mockInstaller(true, '1'); const installer2 = mockInstaller(true, '2'); @@ -72,11 +94,12 @@ suite('Installation - installation channels', () => { assert.notEqual(items![1]!.label!.indexOf('Name 2'), -1, 'Incorrect second installer name'); }); - function mockInstaller(supported: boolean, name: string): TypeMoq.IMock { + function mockInstaller(supported: boolean, name: string, priority?: number): TypeMoq.IMock { const installer = TypeMoq.Mock.ofType(); installer .setup(x => x.isSupported(TypeMoq.It.isAny())) .returns(() => new Promise((resolve) => resolve(supported))); + installer.setup(x => x.priority).returns(() => priority ? priority : 0); serviceManager.addSingletonInstance(IModuleInstaller, installer.object, name); return installer; } diff --git a/src/test/interpreters/interpreterService.test.ts b/src/test/interpreters/interpreterService.test.ts new file mode 100644 index 000000000000..b0c1bc64f1c7 --- /dev/null +++ b/src/test/interpreters/interpreterService.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { Container } from 'inversify'; +import * as path from 'path'; +import * as TypeMoq from 'typemoq'; +import { ConfigurationTarget, Uri, WorkspaceConfiguration } from 'vscode'; +import { IWorkspaceService } from '../../client/common/application/types'; +import { IFileSystem } from '../../client/common/platform/types'; +import { IPythonPathUpdaterServiceManager } from '../../client/interpreter/configuration/types'; +import { + IInterpreterHelper, + IInterpreterLocatorService, + INTERPRETER_LOCATOR_SERVICE, + InterpreterType, + PIPENV_SERVICE, + PythonInterpreter, + WORKSPACE_VIRTUAL_ENV_SERVICE, + WorkspacePythonPath +} from '../../client/interpreter/contracts'; +import { InterpreterService } from '../../client/interpreter/interpreterService'; +import { ServiceContainer } from '../../client/ioc/container'; +import { ServiceManager } from '../../client/ioc/serviceManager'; + +// tslint:disable-next-line:max-func-body-length +suite('Interpreters service', () => { + let serviceManager: ServiceManager; + let serviceContainer: ServiceContainer; + let updater: TypeMoq.IMock; + let helper: TypeMoq.IMock; + let locator: TypeMoq.IMock; + let workspace: TypeMoq.IMock; + let config: TypeMoq.IMock; + let pipenvLocator: TypeMoq.IMock; + let wksLocator: TypeMoq.IMock; + let fileSystem: TypeMoq.IMock; + + setup(async () => { + const cont = new Container(); + serviceManager = new ServiceManager(cont); + serviceContainer = new ServiceContainer(cont); + + updater = TypeMoq.Mock.ofType(); + helper = TypeMoq.Mock.ofType(); + locator = TypeMoq.Mock.ofType(); + workspace = TypeMoq.Mock.ofType(); + config = TypeMoq.Mock.ofType(); + fileSystem = TypeMoq.Mock.ofType(); + + workspace.setup(x => x.getConfiguration('python', TypeMoq.It.isAny())).returns(() => config.object); + serviceManager.addSingletonInstance(IInterpreterHelper, helper.object); + serviceManager.addSingletonInstance(IPythonPathUpdaterServiceManager, updater.object); + serviceManager.addSingletonInstance(IWorkspaceService, workspace.object); + serviceManager.addSingletonInstance(IInterpreterLocatorService, locator.object, INTERPRETER_LOCATOR_SERVICE); + serviceManager.addSingletonInstance(IFileSystem, fileSystem.object); + + pipenvLocator = TypeMoq.Mock.ofType(); + wksLocator = TypeMoq.Mock.ofType(); + }); + + test('autoset interpreter - no workspace', async () => { + await verifyUpdateCalled(TypeMoq.Times.never()); + }); + + test('autoset interpreter - global pythonPath in config', async () => { + setupWorkspace('folder'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python', globalValue: 'global' }; + }); + await verifyUpdateCalled(TypeMoq.Times.never()); + }); + + test('autoset interpreter - workspace has no pythonPath in config', async () => { + setupWorkspace('folder'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python' }; + }); + const interpreter: PythonInterpreter = { + path: path.join(path.sep, 'folder', 'py1', 'bin', 'python.exe'), + type: InterpreterType.Unknown + }; + setupLocators([interpreter], []); + await verifyUpdateCalled(TypeMoq.Times.once()); + }); + + test('autoset interpreter - workspace has default pythonPath in config', async () => { + setupWorkspace('folder'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python', workspaceValue: 'python' }; + }); + setupLocators([], []); + await verifyUpdateCalled(TypeMoq.Times.never()); + }); + + test('autoset interpreter - pipenv workspace', async () => { + setupWorkspace('folder'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python', workspaceValue: 'python' }; + }); + const interpreter: PythonInterpreter = { + path: 'python', + type: InterpreterType.VirtualEnv + }; + setupLocators([], [interpreter]); + await verifyUpdateCallData('python', ConfigurationTarget.Workspace, 'folder'); + }); + + test('autoset interpreter - workspace without interpreter', async () => { + setupWorkspace('root'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python', workspaceValue: 'elsewhere' }; + }); + const interpreter: PythonInterpreter = { + path: 'elsewhere', + type: InterpreterType.Unknown + }; + + setupLocators([interpreter], []); + await verifyUpdateCalled(TypeMoq.Times.never()); + }); + + test('autoset interpreter - workspace with interpreter', async () => { + setupWorkspace('root'); + config.setup(x => x.inspect('pythonPath')).returns(() => { + return { key: 'python' }; + }); + const intPath = path.join(path.sep, 'root', 'under', 'bin', 'python.exe'); + const interpreter: PythonInterpreter = { + path: intPath, + type: InterpreterType.Unknown + }; + + setupLocators([interpreter], []); + await verifyUpdateCallData(intPath, ConfigurationTarget.Workspace, 'root'); + }); + + async function verifyUpdateCalled(times: TypeMoq.Times) { + const service = new InterpreterService(serviceContainer); + await service.autoSetInterpreter(); + updater + .verify(x => x.updatePythonPath(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()), times); + } + + async function verifyUpdateCallData(pythonPath: string, target: ConfigurationTarget, wksFolder: string) { + let pp: string | undefined; + let confTarget: ConfigurationTarget | undefined; + let trigger; + let wks; + updater + .setup(x => x.updatePythonPath(TypeMoq.It.isAnyString(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + // tslint:disable-next-line:no-any + .callback((p: string, c: ConfigurationTarget, t: any, w: any) => { + pp = p; + confTarget = c; + trigger = t; + wks = w; + }) + .returns(() => Promise.resolve()); + + const service = new InterpreterService(serviceContainer); + await service.autoSetInterpreter(); + + expect(pp).not.to.be.equal(undefined, 'updatePythonPath not called'); + expect(pp!).to.be.equal(pythonPath, 'invalid Python path'); + expect(confTarget).to.be.equal(target, 'invalid configuration target'); + expect(trigger).to.be.equal('load', 'invalid trigger'); + expect(wks.fsPath).to.be.equal(`${path.sep}${wksFolder}`, 'invalid workspace Uri'); + } + + function setupWorkspace(folder: string) { + const wsPath: WorkspacePythonPath = { + folderUri: Uri.file(folder), + configTarget: ConfigurationTarget.Workspace + }; + helper.setup(x => x.getActiveWorkspaceUri()).returns(() => wsPath); + } + + function setupLocators(wks: PythonInterpreter[], pipenv: PythonInterpreter[]) { + pipenvLocator.setup(x => x.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve(pipenv)); + serviceManager.addSingletonInstance(IInterpreterLocatorService, pipenvLocator.object, PIPENV_SERVICE); + wksLocator.setup(x => x.getInterpreters(TypeMoq.It.isAny())).returns(() => Promise.resolve(wks)); + serviceManager.addSingletonInstance(IInterpreterLocatorService, wksLocator.object, WORKSPACE_VIRTUAL_ENV_SERVICE); + + } +}); diff --git a/src/test/interpreters/interpreterVersion.test.ts b/src/test/interpreters/interpreterVersion.test.ts index 5478edab591e..6bd728d669ab 100644 --- a/src/test/interpreters/interpreterVersion.test.ts +++ b/src/test/interpreters/interpreterVersion.test.ts @@ -6,6 +6,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import '../../client/common/extensions'; import { IProcessService } from '../../client/common/process/types'; import { IInterpreterVersionService } from '../../client/interpreter/contracts'; +import { PIP_VERSION_REGEX } from '../../client/interpreter/interpreterVersion'; import { initialize, initializeTest } from '../initialize'; import { UnitTestIocContainer } from '../unittests/serviceRegistry'; @@ -47,7 +48,7 @@ suite('Interpreters display version', () => { const output = result.stdout.splitLines()[0]; // Take the second part, see below example. // pip 9.0.1 from /Users/donjayamanne/anaconda3/lib/python3.6/site-packages (python 3.6). - const re = new RegExp('\\d\\.\\d(\\.\\d)+', 'g'); + const re = new RegExp(PIP_VERSION_REGEX, 'g'); const matches = re.exec(output); assert.isNotNull(matches, 'No matches for version found'); // tslint:disable-next-line:no-non-null-assertion diff --git a/src/test/interpreters/venv.test.ts b/src/test/interpreters/venv.test.ts index eb1d8dd27bcd..7c26035e1096 100644 --- a/src/test/interpreters/venv.test.ts +++ b/src/test/interpreters/venv.test.ts @@ -42,21 +42,28 @@ suite('Virtual environments', () => { test('Global search paths', async () => { const pathProvider = new GlobalVirtualEnvironmentsSearchPathProvider(serviceContainer); - const envMap: EnvironmentVariables = {}; const homedir = os.homedir(); - let folders = ['Envs', '.virtualenvs', '.pyenv', path.join('.pyenv', 'versions')]; + const folders = ['Envs', '.virtualenvs', '.pyenv']; + settings.setup(x => x.venvFolders).returns(() => folders); + let paths = pathProvider.getSearchPaths(); let expected = folders.map(item => path.join(homedir, item)); + expected.push(path.join('.pyenv', 'versions')); + expect(paths).to.deep.equal(expected, 'Global search folder list is incorrect.'); + const envMap: EnvironmentVariables = {}; process.setup(x => x.env).returns(() => envMap); + + const customFolder = path.join(homedir, 'some_folder'); // tslint:disable-next-line:no-string-literal - envMap['PYENV_ROOT'] = path.join(homedir, 'some_folder'); + envMap['PYENV_ROOT'] = customFolder; paths = pathProvider.getSearchPaths(); - folders = ['Envs', '.virtualenvs', 'some_folder', path.join('some_folder', 'versions')]; expected = folders.map(item => path.join(homedir, item)); + expected.push(customFolder); + expected.push(path.join(customFolder, 'versions')); expect(paths).to.deep.equal(expected, 'PYENV_ROOT not resolved correctly.'); }); diff --git a/src/test/mocks/moduleInstaller.ts b/src/test/mocks/moduleInstaller.ts index e6ab25ac4f57..565c1c63c662 100644 --- a/src/test/mocks/moduleInstaller.ts +++ b/src/test/mocks/moduleInstaller.ts @@ -6,6 +6,9 @@ export class MockModuleInstaller extends EventEmitter implements IModuleInstalle constructor(public readonly displayName: string, private supported: boolean) { super(); } + public get priority(): number { + return 0; + } public async installModule(name: string, resource?: Uri): Promise { this.emit('installModule', name); } diff --git a/src/test/serviceRegistry.ts b/src/test/serviceRegistry.ts index 422501a6cd07..f38344a474bd 100644 --- a/src/test/serviceRegistry.ts +++ b/src/test/serviceRegistry.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Container } from 'inversify'; -import { Disposable, Memento, OutputChannel, Uri } from 'vscode'; +import { Disposable, Memento, OutputChannel } from 'vscode'; import { STANDARD_OUTPUT_CHANNEL } from '../client/common/constants'; import { Logger } from '../client/common/logger'; import { IS_64_BIT, IS_WINDOWS } from '../client/common/platform/constants'; @@ -56,11 +56,6 @@ export class IocContainer { this.disposables.push(testOutputChannel); this.serviceManager.addSingletonInstance(IOutputChannel, testOutputChannel, TEST_OUTPUT_CHANNEL); } - public async getPythonVersion(resource?: string | Uri): Promise { - const factory = this.serviceContainer.get(IPythonExecutionFactory); - const resourceToUse = (typeof resource === 'string') ? Uri.file(resource as string) : (resource as Uri); - return factory.create(resourceToUse).then(pythonProc => pythonProc.getVersion()); - } public dispose() { this.disposables.forEach(disposable => disposable.dispose()); }