diff --git a/news/1 Enhancements/11839.md b/news/1 Enhancements/11839.md new file mode 100644 index 000000000000..2aff8c158784 --- /dev/null +++ b/news/1 Enhancements/11839.md @@ -0,0 +1 @@ +Added Poetry interpreter support (thanks [Terry Cain](https://github.com/terrycain)) diff --git a/package.nls.json b/package.nls.json index 0da7d3e53b31..845acdd475ef 100644 --- a/package.nls.json +++ b/package.nls.json @@ -144,6 +144,8 @@ "Interpreters.LoadingInterpreters": "Loading Python Interpreters", "Interpreters.unsafeInterpreterMessage": "We found a Python environment in this workspace. Do you want to select it to start up the features in the Python extension? Only accept if you trust this environment.", "Interpreters.condaInheritEnvMessage": "We noticed you're using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we recommend that you let the Python extension change \"terminal.integrated.inheritEnv\" to false in your user settings.", + "Interpreters.poetryVenvMissing" : "Workspace contains pyproject.toml but the associated virtual environment has not been setup. Setup the virtual environment manually if needed.", + "Interpreters.poetryBinaryMissing" : "Workspace contains pyproject.toml but '{0}' was not found. Make sure '{0}' is on the PATH.", "Logging.CurrentWorkingDirectory": "cwd:", "InterpreterQuickPickList.quickPickListPlaceholder": "Current: {0}", "InterpreterQuickPickList.enterPath.detail": "Enter path or find an existing interpreter", diff --git a/src/client/common/process/types.ts b/src/client/common/process/types.ts index 03aee362dd8d..ee072d7158a0 100644 --- a/src/client/common/process/types.ts +++ b/src/client/common/process/types.ts @@ -170,6 +170,7 @@ export type InterpreterInfomation = { architecture: Architecture; sysPrefix: string; pipEnvWorkspaceFolder?: string; + poetryWorkspaceFolder?: string; }; export const IPythonExecutionService = Symbol('IPythonExecutionService'); diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 2da2df56bd49..ff7a65c941c6 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -172,6 +172,15 @@ export namespace Interpreters { 'Tip: you can change the Python interpreter used by the Python extension by clicking on the Python version in the status bar' ); export const pythonInterpreterPath = localize('Interpreters.pythonInterpreterPath', 'Python interpreter path: {0}'); + + export const poetryVenvMissing = localize( + 'Interpreters.poetryVenvMissing', + 'Workspace contains pyproject.toml but the associated virtual environment has not been setup. Setup the virtual environment manually if needed.' + ); + export const poetryBinaryMissing = localize( + 'Interpreters.poetryBinaryMissing', + "Workspace contains pyproject.toml but '{0}' was not found. Make sure '{0}' is on the PATH." + ); } export namespace InterpreterQuickPickList { diff --git a/src/client/interpreter/autoSelection/rules/system.ts b/src/client/interpreter/autoSelection/rules/system.ts index 9feb4925b9a4..d90cd3f9ab41 100644 --- a/src/client/interpreter/autoSelection/rules/system.ts +++ b/src/client/interpreter/autoSelection/rules/system.ts @@ -31,7 +31,8 @@ export class SystemWideInterpretersAutoSelectionRule extends BaseRuleService { (int) => int.type !== InterpreterType.VirtualEnv && int.type !== InterpreterType.Venv && - int.type !== InterpreterType.Pipenv + int.type !== InterpreterType.Pipenv && + int.type !== InterpreterType.Poetry ); const bestInterpreter = this.helper.getBestInterpreter(filteredInterpreters); traceVerbose( diff --git a/src/client/interpreter/contracts.ts b/src/client/interpreter/contracts.ts index 6013ff504bd2..f9061850bd9b 100644 --- a/src/client/interpreter/contracts.ts +++ b/src/client/interpreter/contracts.ts @@ -12,6 +12,7 @@ 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 POETRY_SERVICE = 'PoetryService'; export const IInterpreterVersionService = Symbol('IInterpreterVersionService'); export interface IInterpreterVersionService { getVersion(pythonPath: string, defaultValue: string): Promise; @@ -76,6 +77,7 @@ export enum InterpreterType { Conda = 'Conda', VirtualEnv = 'VirtualEnv', Pipenv = 'PipEnv', + Poetry = 'Poetry', Pyenv = 'Pyenv', Venv = 'Venv', WindowsStore = 'WindowsStore' @@ -132,6 +134,12 @@ export interface IPipEnvService extends IInterpreterLocatorService { isRelatedPipEnvironment(dir: string, pythonPath: string): Promise; } +export const IPoetryService = Symbol('IPoetryService'); +export interface IPoetryService extends IInterpreterLocatorService { + executable: string; + isRelatedPoetryEnvironment(dir: string, pythonPath: string): Promise; +} + export const IInterpreterLocatorHelper = Symbol('IInterpreterLocatorHelper'); export interface IInterpreterLocatorHelper { mergeInterpreters(interpreters: PythonInterpreter[]): Promise; diff --git a/src/client/interpreter/helpers.ts b/src/client/interpreter/helpers.ts index 84f6a30a4894..77ce9e27d0d3 100644 --- a/src/client/interpreter/helpers.ts +++ b/src/client/interpreter/helpers.ts @@ -31,7 +31,6 @@ export function isInterpreterLocatedInWorkspace(interpreter: PythonInterpreter, const resourcePath = fileSystemPaths.normCase(activeWorkspaceUri.fsPath); return interpreterPath.startsWith(resourcePath); } - @injectable() export class InterpreterHelper implements IInterpreterHelper { private readonly persistentFactory: IPersistentStateFactory; @@ -114,6 +113,9 @@ export class InterpreterHelper implements IInterpreterHelper { case InterpreterType.Pipenv: { return 'pipenv'; } + case InterpreterType.Poetry: { + return 'poetry'; + } case InterpreterType.Pyenv: { return 'pyenv'; } diff --git a/src/client/interpreter/interpreterService.ts b/src/client/interpreter/interpreterService.ts index 6eef287fe8e4..8f19a3ea3f21 100644 --- a/src/client/interpreter/interpreterService.ts +++ b/src/client/interpreter/interpreterService.ts @@ -292,7 +292,12 @@ export class InterpreterService implements Disposable, IInterpreterService { if (info.architecture) { displayNameParts.push(getArchitectureDisplayName(info.architecture)); } - if (!info.envName && info.path && info.type && info.type === InterpreterType.Pipenv) { + if ( + !info.envName && + info.path && + info.type && + (info.type === InterpreterType.Pipenv || info.type === InterpreterType.Poetry) + ) { // If we do not have the name of the environment, then try to get it again. // This can happen based on the context (i.e. resource). // I.e. we can determine if an environment is PipEnv only when giving it the right workspacec path (i.e. resource). diff --git a/src/client/interpreter/locators/helpers.ts b/src/client/interpreter/locators/helpers.ts index d19fb42894ed..973692896c03 100644 --- a/src/client/interpreter/locators/helpers.ts +++ b/src/client/interpreter/locators/helpers.ts @@ -1,9 +1,10 @@ import * as fsapi from 'fs-extra'; import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { traceError } from '../../common/logger'; +import { traceError, traceWarning } from '../../common/logger'; import { IS_WINDOWS } from '../../common/platform/constants'; -import { IFileSystem } from '../../common/platform/types'; +import { IFileSystem, IPlatformService } from '../../common/platform/types'; +import { ICurrentProcess } from '../../common/types'; import { IInterpreterLocatorHelper, InterpreterType, PythonInterpreter } from '../contracts'; import { IPipEnvServiceHelper } from './types'; @@ -25,6 +26,22 @@ export async function lookForInterpretersInDirectory(pathToCheck: string, _: IFi } } +export function traceProcessError( + platformService: IPlatformService, + currentProcess: ICurrentProcess, + error: Error, + name: string +) { + const enviromentVariableValues: Record = { + LC_ALL: currentProcess.env.LC_ALL, + LANG: currentProcess.env.LANG + }; + enviromentVariableValues[platformService.pathVariableName] = currentProcess.env[platformService.pathVariableName]; + + traceWarning(`Error in invoking ${name}`, error); + traceWarning(`Relevant Environment Variables ${JSON.stringify(enviromentVariableValues, undefined, 4)}`); +} + @injectable() export class InterpreterLocatorHelper implements IInterpreterLocatorHelper { constructor( @@ -91,6 +108,9 @@ export class InterpreterLocatorHelper implements IInterpreterLocatorHelper { item.pipEnvWorkspaceFolder = info.workspaceFolder.fsPath; item.envName = info.envName || item.envName; } + if (item.path.includes('pypoetry')) { + item.type = InterpreterType.Poetry; + } }) ); return items; diff --git a/src/client/interpreter/locators/index.ts b/src/client/interpreter/locators/index.ts index dc65eead948f..e7487936031c 100644 --- a/src/client/interpreter/locators/index.ts +++ b/src/client/interpreter/locators/index.ts @@ -16,6 +16,7 @@ import { IInterpreterLocatorService, KNOWN_PATH_SERVICE, PIPENV_SERVICE, + POETRY_SERVICE, PythonInterpreter, WINDOWS_REGISTRY_SERVICE, WORKSPACE_VIRTUAL_ENV_SERVICE @@ -115,6 +116,7 @@ export class PythonInterpreterLocatorService implements IInterpreterLocatorServi [CONDA_ENV_SERVICE, undefined], [CONDA_ENV_FILE_SERVICE, undefined], [PIPENV_SERVICE, undefined], + [POETRY_SERVICE, undefined], [GLOBAL_VIRTUAL_ENV_SERVICE, undefined], [WORKSPACE_VIRTUAL_ENV_SERVICE, undefined], [KNOWN_PATH_SERVICE, undefined], diff --git a/src/client/interpreter/locators/services/pipEnvService.ts b/src/client/interpreter/locators/services/pipEnvService.ts index f0459c51806b..ff99046f10c3 100644 --- a/src/client/interpreter/locators/services/pipEnvService.ts +++ b/src/client/interpreter/locators/services/pipEnvService.ts @@ -5,7 +5,7 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; import { Uri } from 'vscode'; import { IApplicationShell, IWorkspaceService } from '../../../common/application/types'; -import { traceError, traceWarning } from '../../../common/logger'; +import { traceError } from '../../../common/logger'; import { IFileSystem, IPlatformService } from '../../../common/platform/types'; import { IProcessServiceFactory } from '../../../common/process/types'; import { IConfigurationService, ICurrentProcess } from '../../../common/types'; @@ -20,6 +20,7 @@ import { IPipEnvService, PythonInterpreter } from '../../contracts'; +import { traceProcessError } from '../helpers'; import { IPipEnvServiceHelper } from '../types'; import { CacheableLocatorService } from './cacheableLocatorService'; @@ -190,15 +191,7 @@ export class PipEnvService extends CacheableLocatorService implements IPipEnvSer } catch (error) { const platformService = this.serviceContainer.get(IPlatformService); const currentProc = this.serviceContainer.get(ICurrentProcess); - const enviromentVariableValues: Record = { - LC_ALL: currentProc.env.LC_ALL, - LANG: currentProc.env.LANG - }; - enviromentVariableValues[platformService.pathVariableName] = - currentProc.env[platformService.pathVariableName]; - - traceWarning('Error in invoking PipEnv', error); - traceWarning(`Relevant Environment Variables ${JSON.stringify(enviromentVariableValues, undefined, 4)}`); + traceProcessError(platformService, currentProc, error, 'PipEnv'); } } } diff --git a/src/client/interpreter/locators/services/poetryService.ts b/src/client/interpreter/locators/services/poetryService.ts new file mode 100644 index 000000000000..a49b4a7fa810 --- /dev/null +++ b/src/client/interpreter/locators/services/poetryService.ts @@ -0,0 +1,181 @@ +// 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 { traceError } from '../../../common/logger'; +import { IFileSystem, IPlatformService } from '../../../common/platform/types'; +import { IProcessServiceFactory } from '../../../common/process/types'; +import { IConfigurationService, ICurrentProcess } from '../../../common/types'; +import { Interpreters } from '../../../common/utils/localize'; +import { StopWatch } from '../../../common/utils/stopWatch'; +import { IServiceContainer } from '../../../ioc/types'; +import { sendTelemetryEvent } from '../../../telemetry'; +import { EventName } from '../../../telemetry/constants'; +import { + GetInterpreterLocatorOptions, + IInterpreterHelper, + InterpreterType, + IPoetryService, + PythonInterpreter +} from '../../contracts'; +import { traceProcessError } from '../helpers'; +import { IPipEnvServiceHelper } from '../types'; +import { CacheableLocatorService } from './cacheableLocatorService'; + +@injectable() +export class PoetryService extends CacheableLocatorService implements IPoetryService { + private readonly helper: IInterpreterHelper; + private readonly processServiceFactory: IProcessServiceFactory; + private readonly workspace: IWorkspaceService; + private readonly fs: IFileSystem; + private readonly configService: IConfigurationService; + private readonly poetryServiceHelper: IPipEnvServiceHelper; + + constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { + super('PoetryService', serviceContainer, true); + this.helper = this.serviceContainer.get(IInterpreterHelper); + this.processServiceFactory = this.serviceContainer.get(IProcessServiceFactory); + this.workspace = this.serviceContainer.get(IWorkspaceService); + this.fs = this.serviceContainer.get(IFileSystem); + this.configService = this.serviceContainer.get(IConfigurationService); + this.poetryServiceHelper = this.serviceContainer.get(IPipEnvServiceHelper); + } + + // tslint:disable-next-line:no-empty + public dispose() {} + + public async isRelatedPoetryEnvironment(dir: string, pythonPath: string): Promise { + if (!this.didTriggerInterpreterSuggestions) { + return false; + } + + // In PipEnv, the name of the cwd is used as a prefix in the virtual env. + if (pythonPath.indexOf(`${path.sep}${path.basename(dir)}-`) === -1) { + return false; + } + const envName = await this.getInterpreterPathFromPoetry(dir, true); + return !!envName; + } + + public get executable(): string { + return this.didTriggerInterpreterSuggestions ? this.configService.getSettings().poetryPath : ''; + } + + public async getInterpreters(resource?: Uri, options?: GetInterpreterLocatorOptions): Promise { + if (!this.didTriggerInterpreterSuggestions) { + return []; + } + + const stopwatch = new StopWatch(); + const startDiscoveryTime = stopwatch.elapsedTime; + + const interpreters = await super.getInterpreters(resource, options); + + const discoveryDuration = stopwatch.elapsedTime - startDiscoveryTime; + sendTelemetryEvent(EventName.POETRY_INTERPRETER_DISCOVERY, discoveryDuration); + + return interpreters; + } + + protected getInterpretersImplementation(resource?: Uri): Promise { + if (!this.didTriggerInterpreterSuggestions) { + return Promise.resolve([]); + } + + const poetryCwd = this.getPoetryWorkingDirectory(resource); + if (!poetryCwd) { + return Promise.resolve([]); + } + + return this.getInterpreterFromPoetry(poetryCwd) + .then((item) => (item ? [item] : [])) + .catch(() => []); + } + + private async getInterpreterFromPoetry(poetryCwd: string): Promise { + const interpreterPath = await this.getInterpreterPathFromPoetry(poetryCwd); + if (!interpreterPath) { + return; + } + + const details = await this.helper.getInterpreterInformation(interpreterPath); + if (!details) { + return; + } + this._hasInterpreters.resolve(true); + await this.poetryServiceHelper.trackWorkspaceFolder(interpreterPath, Uri.file(poetryCwd)); + return { + ...(details as PythonInterpreter), + path: interpreterPath, + type: InterpreterType.Poetry, + poetryWorkspaceFolder: poetryCwd + }; + } + + private getPoetryWorkingDirectory(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 getInterpreterPathFromPoetry(cwd: string, ignoreErrors = false): Promise { + // Quick check before actually running poetry + if (!(await this.checkIfPoetryFileExists(cwd))) { + return; + } + try { + // call poetry --help just to see if poetryWorkspaceFolder?: string; is in the PATH + const version = await this.invokePoetry(['--help'], cwd); + if (version === undefined) { + const appShell = this.serviceContainer.get(IApplicationShell); + appShell.showWarningMessage(Interpreters.poetryBinaryMissing().format(this.executable)); + return; + } + // env info -p will be empty if a virtualenv has not been created yet + const venv = await this.invokePoetry(['env', 'info', '-p'], cwd); + if (venv === '') { + const appShell = this.serviceContainer.get(IApplicationShell); + appShell.showWarningMessage(Interpreters.poetryVenvMissing()); + return; + } + const pythonPath = `${venv}/bin/python`; + return pythonPath && (await this.fs.fileExists(pythonPath)) ? pythonPath : undefined; + // tslint:disable-next-line:no-empty + } catch (error) { + traceError('Poetry identification failed', error); + if (ignoreErrors) { + return; + } + } + } + + private async checkIfPoetryFileExists(cwd: string): Promise { + if (await this.fs.fileExists(path.join(cwd, 'pyproject.toml'))) { + return true; + } + return false; + } + + private async invokePoetry(arg: string[], rootPath: string): Promise { + try { + const processService = await this.processServiceFactory.create(Uri.file(rootPath)); + const execName = this.executable; + const result = await processService.exec(execName, arg, { cwd: rootPath }); + if (result) { + return result.stdout ? result.stdout.trim() : ''; + } + // tslint:disable-next-line:no-empty + } catch (error) { + const platformService = this.serviceContainer.get(IPlatformService); + const currentProc = this.serviceContainer.get(ICurrentProcess); + traceProcessError(platformService, currentProc, error, 'Poetry'); + } + } +} diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index d4602eb61363..925dc9406c16 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -64,6 +64,7 @@ import { IVirtualEnvironmentsSearchPathProvider, KNOWN_PATH_SERVICE, PIPENV_SERVICE, + POETRY_SERVICE, WINDOWS_REGISTRY_SERVICE, WORKSPACE_VIRTUAL_ENV_SERVICE } from './contracts'; @@ -92,6 +93,7 @@ import { InterpreterWatcherBuilder } from './locators/services/interpreterWatche import { KnownPathsService, KnownSearchPathsForInterpreters } from './locators/services/KnownPathsService'; import { PipEnvService } from './locators/services/pipEnvService'; import { PipEnvServiceHelper } from './locators/services/pipEnvServiceHelper'; +import { PoetryService } from './locators/services/poetryService'; import { WindowsRegistryService } from './locators/services/windowsRegistryService'; import { WindowsStoreInterpreter } from './locators/services/windowsStoreInterpreter'; import { @@ -199,6 +201,7 @@ export function registerInterpreterTypes(serviceManager: IServiceManager) { WORKSPACE_VIRTUAL_ENV_SERVICE ); serviceManager.addSingleton(IInterpreterLocatorService, PipEnvService, PIPENV_SERVICE); + serviceManager.addSingleton(IInterpreterLocatorService, PoetryService, POETRY_SERVICE); serviceManager.addSingleton( IInterpreterLocatorService, diff --git a/src/client/interpreter/virtualEnvs/index.ts b/src/client/interpreter/virtualEnvs/index.ts index 34778820d6f2..2ba0d581e3a3 100644 --- a/src/client/interpreter/virtualEnvs/index.ts +++ b/src/client/interpreter/virtualEnvs/index.ts @@ -12,7 +12,14 @@ import { ICurrentProcess, IPathUtils } from '../../common/types'; import { getNamesAndValues } from '../../common/utils/enum'; import { noop } from '../../common/utils/misc'; import { IServiceContainer } from '../../ioc/types'; -import { IInterpreterLocatorService, InterpreterType, IPipEnvService, PIPENV_SERVICE } from '../contracts'; +import { + IInterpreterLocatorService, + InterpreterType, + IPipEnvService, + IPoetryService, + PIPENV_SERVICE, + POETRY_SERVICE +} from '../contracts'; import { IVirtualEnvironmentManager } from './types'; const PYENVFILES = ['pyvenv.cfg', path.join('..', 'pyvenv.cfg')]; @@ -21,6 +28,7 @@ const PYENVFILES = ['pyvenv.cfg', path.join('..', 'pyvenv.cfg')]; export class VirtualEnvironmentManager implements IVirtualEnvironmentManager { private processServiceFactory: IProcessServiceFactory; private pipEnvService: IPipEnvService; + private poetryService: IPoetryService; private fs: IFileSystem; private pyEnvRoot?: string; private workspaceService: IWorkspaceService; @@ -31,6 +39,10 @@ export class VirtualEnvironmentManager implements IVirtualEnvironmentManager { IInterpreterLocatorService, PIPENV_SERVICE ) as IPipEnvService; + this.poetryService = serviceContainer.get( + IInterpreterLocatorService, + POETRY_SERVICE + ) as IPoetryService; this.workspaceService = serviceContainer.get(IWorkspaceService); } public async getEnvironmentName(pythonPath: string, resource?: Uri): Promise { @@ -41,7 +53,11 @@ export class VirtualEnvironmentManager implements IVirtualEnvironmentManager { const workspaceUri = workspaceFolder ? workspaceFolder.uri : defaultWorkspaceUri; const grandParentDirName = path.basename(path.dirname(path.dirname(pythonPath))); - if (workspaceUri && (await this.pipEnvService.isRelatedPipEnvironment(workspaceUri.fsPath, pythonPath))) { + if ( + workspaceUri && + ((await this.pipEnvService.isRelatedPipEnvironment(workspaceUri.fsPath, pythonPath)) || + (await this.poetryService.isRelatedPoetryEnvironment(workspaceUri.fsPath, pythonPath))) + ) { // In pipenv, return the folder name of the workspace. return path.basename(workspaceUri.fsPath); } @@ -61,6 +77,10 @@ export class VirtualEnvironmentManager implements IVirtualEnvironmentManager { return InterpreterType.Pipenv; } + if (await this.isPoetryEnvironment(pythonPath, resource)) { + return InterpreterType.Poetry; + } + if (await this.isVirtualEnvironment(pythonPath)) { return InterpreterType.VirtualEnv; } @@ -93,6 +113,17 @@ export class VirtualEnvironmentManager implements IVirtualEnvironmentManager { } return false; } + public async isPoetryEnvironment(pythonPath: string, resource?: Uri) { + const defaultWorkspaceUri = this.workspaceService.hasWorkspaceFolders + ? this.workspaceService.workspaceFolders![0].uri + : undefined; + const workspaceFolder = resource ? this.workspaceService.getWorkspaceFolder(resource) : undefined; + const workspaceUri = workspaceFolder ? workspaceFolder.uri : defaultWorkspaceUri; + if (workspaceUri && (await this.poetryService.isRelatedPoetryEnvironment(workspaceUri.fsPath, pythonPath))) { + return true; + } + return false; + } public async getPyEnvRoot(resource?: Uri): Promise { if (this.pyEnvRoot) { return this.pyEnvRoot; diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index f1bb3508b303..cb989fb5a747 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -29,6 +29,7 @@ export enum EventName { PYTHON_INTERPRETER_ACTIVATION_FOR_RUNNING_CODE = 'PYTHON_INTERPRETER_ACTIVATION_FOR_RUNNING_CODE', PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL = 'PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL', PIPENV_INTERPRETER_DISCOVERY = 'PIPENV_INTERPRETER_DISCOVERY', + POETRY_INTERPRETER_DISCOVERY = 'POETRY_INTERPRETER_DISCOVERY', TERMINAL_SHELL_IDENTIFICATION = 'TERMINAL_SHELL_IDENTIFICATION', PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT = 'PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT', PYTHON_NOT_INSTALLED_PROMPT = 'PYTHON_NOT_INSTALLED_PROMPT', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index c573e960a326..7522bcdac598 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1046,6 +1046,10 @@ export interface IEventNamePropertyMapping { * Telemetry event sent when pipenv interpreter discovery is executed. */ [EventName.PIPENV_INTERPRETER_DISCOVERY]: never | undefined; + /** + * Telemetry event sent when poetry interpreter discovery is executed. + */ + [EventName.POETRY_INTERPRETER_DISCOVERY]: never | undefined; /** * Telemetry event sent with details when user clicks the prompt with the following message * `Prompt message` :- 'We noticed you're using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we suggest the "terminal.integrated.inheritEnv" setting to be changed to false. Would you like to update this setting?' diff --git a/src/test/interpreters/locators/index.unit.test.ts b/src/test/interpreters/locators/index.unit.test.ts index c4c0fac23ae9..87503adb3ba5 100644 --- a/src/test/interpreters/locators/index.unit.test.ts +++ b/src/test/interpreters/locators/index.unit.test.ts @@ -23,6 +23,7 @@ import { InterpreterType, KNOWN_PATH_SERVICE, PIPENV_SERVICE, + POETRY_SERVICE, PythonInterpreter, WINDOWS_REGISTRY_SERVICE, WORKSPACE_VIRTUAL_ENV_SERVICE @@ -69,6 +70,7 @@ suite('Interpreters - Locators Index', () => { locatorsTypes.push(CONDA_ENV_SERVICE); locatorsTypes.push(CONDA_ENV_FILE_SERVICE); locatorsTypes.push(PIPENV_SERVICE); + locatorsTypes.push(POETRY_SERVICE); locatorsTypes.push(GLOBAL_VIRTUAL_ENV_SERVICE); locatorsTypes.push(WORKSPACE_VIRTUAL_ENV_SERVICE); locatorsTypes.push(KNOWN_PATH_SERVICE); @@ -132,6 +134,7 @@ suite('Interpreters - Locators Index', () => { locatorsTypes.push(CONDA_ENV_SERVICE); locatorsTypes.push(CONDA_ENV_FILE_SERVICE); locatorsTypes.push(PIPENV_SERVICE); + locatorsTypes.push(POETRY_SERVICE); locatorsTypes.push(GLOBAL_VIRTUAL_ENV_SERVICE); locatorsTypes.push(WORKSPACE_VIRTUAL_ENV_SERVICE); locatorsTypes.push(KNOWN_PATH_SERVICE); @@ -198,6 +201,7 @@ suite('Interpreters - Locators Index', () => { locatorsTypes.push(CONDA_ENV_SERVICE); locatorsTypes.push(CONDA_ENV_FILE_SERVICE); locatorsTypes.push(PIPENV_SERVICE); + locatorsTypes.push(POETRY_SERVICE); locatorsTypes.push(GLOBAL_VIRTUAL_ENV_SERVICE); locatorsTypes.push(WORKSPACE_VIRTUAL_ENV_SERVICE); locatorsTypes.push(KNOWN_PATH_SERVICE); diff --git a/src/test/interpreters/poetryService.unit.test.ts b/src/test/interpreters/poetryService.unit.test.ts new file mode 100644 index 000000000000..f77c63c11f56 --- /dev/null +++ b/src/test/interpreters/poetryService.unit.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:max-func-body-length no-any + +import * as assert from 'assert'; +import { expect } from 'chai'; +import * as path from 'path'; +import { SemVer } from 'semver'; +import * as sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import * as TypeMoq from 'typemoq'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { IApplicationShell, IWorkspaceService } from '../../client/common/application/types'; +import { IFileSystem, IPlatformService } from '../../client/common/platform/types'; +import { IProcessService, IProcessServiceFactory } from '../../client/common/process/types'; +import { + IConfigurationService, + ICurrentProcess, + IPersistentState, + IPersistentStateFactory, + IPythonSettings +} from '../../client/common/types'; +import { getNamesAndValues } from '../../client/common/utils/enum'; +import { IEnvironmentVariablesProvider } from '../../client/common/variables/types'; +import { IInterpreterHelper } from '../../client/interpreter/contracts'; +import { PipEnvServiceHelper } from '../../client/interpreter/locators/services/pipEnvServiceHelper'; +import { PoetryService } from '../../client/interpreter/locators/services/poetryService'; +import { IPipEnvServiceHelper } from '../../client/interpreter/locators/types'; +import { IServiceContainer } from '../../client/ioc/types'; +import * as Telemetry from '../../client/telemetry'; +import { EventName } from '../../client/telemetry/constants'; + +enum OS { + Mac, + Windows, + Linux +} + +suite('Interpreters - Poetry', () => { + const rootWorkspace = Uri.file(path.join('usr', 'desktop', 'wkspc1')).fsPath; + getNamesAndValues(OS).forEach((os) => { + [undefined, Uri.file(path.join(rootWorkspace, 'one.py'))].forEach((resource) => { + const testSuffix = ` (${os.name}, ${resource ? 'with' : 'without'} a workspace)`; + + let poetryService: PoetryService; + let serviceContainer: TypeMoq.IMock; + let interpreterHelper: TypeMoq.IMock; + let processService: TypeMoq.IMock; + let currentProcess: TypeMoq.IMock; + let fileSystem: TypeMoq.IMock; + let appShell: TypeMoq.IMock; + let persistentStateFactory: TypeMoq.IMock; + let envVarsProvider: TypeMoq.IMock; + let procServiceFactory: TypeMoq.IMock; + let platformService: TypeMoq.IMock; + let config: TypeMoq.IMock; + let settings: TypeMoq.IMock; + let poetryPathSetting: string; + let poetryServiceHelper: IPipEnvServiceHelper; + + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + const workspaceService = TypeMoq.Mock.ofType(); + interpreterHelper = TypeMoq.Mock.ofType(); + fileSystem = TypeMoq.Mock.ofType(); + processService = TypeMoq.Mock.ofType(); + appShell = TypeMoq.Mock.ofType(); + currentProcess = TypeMoq.Mock.ofType(); + persistentStateFactory = TypeMoq.Mock.ofType(); + envVarsProvider = TypeMoq.Mock.ofType(); + procServiceFactory = TypeMoq.Mock.ofType(); + platformService = TypeMoq.Mock.ofType(); + poetryServiceHelper = mock(PipEnvServiceHelper); + processService.setup((x: any) => x.then).returns(() => undefined); + procServiceFactory + .setup((p) => p.create(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(processService.object)); + + // tslint:disable-next-line:no-any + const persistentState = TypeMoq.Mock.ofType>(); + persistentStateFactory + .setup((p) => p.createGlobalPersistentState(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => persistentState.object); + persistentStateFactory + .setup((p) => p.createWorkspacePersistentState(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => persistentState.object); + persistentState.setup((p) => p.value).returns(() => undefined); + persistentState.setup((p) => p.updateValue(TypeMoq.It.isAny())).returns(() => Promise.resolve()); + + const workspaceFolder = TypeMoq.Mock.ofType(); + workspaceFolder.setup((w) => w.uri).returns(() => Uri.file(rootWorkspace)); + workspaceService + .setup((w) => w.getWorkspaceFolder(TypeMoq.It.isAny())) + .returns(() => workspaceFolder.object); + workspaceService.setup((w) => w.rootPath).returns(() => rootWorkspace); + + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IProcessServiceFactory), TypeMoq.It.isAny())) + .returns(() => procServiceFactory.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IWorkspaceService))) + .returns(() => workspaceService.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IInterpreterHelper))) + .returns(() => interpreterHelper.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(ICurrentProcess))) + .returns(() => currentProcess.object); + serviceContainer.setup((c) => c.get(TypeMoq.It.isValue(IFileSystem))).returns(() => fileSystem.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IApplicationShell))) + .returns(() => appShell.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IPersistentStateFactory))) + .returns(() => persistentStateFactory.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IEnvironmentVariablesProvider))) + .returns(() => envVarsProvider.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IPlatformService))) + .returns(() => platformService.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IConfigurationService), TypeMoq.It.isAny())) + .returns(() => config.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IPipEnvServiceHelper), TypeMoq.It.isAny())) + .returns(() => instance(poetryServiceHelper)); + + when(poetryServiceHelper.trackWorkspaceFolder(anything(), anything())).thenResolve(); + config = TypeMoq.Mock.ofType(); + settings = TypeMoq.Mock.ofType(); + config.setup((c) => c.getSettings(TypeMoq.It.isValue(undefined))).returns(() => settings.object); + settings.setup((p) => p.poetryPath).returns(() => poetryPathSetting); + poetryPathSetting = 'poetry'; + + poetryService = new PoetryService(serviceContainer.object); + }); + + suite('With didTriggerInterpreterSuggestions set to true', () => { + setup(() => { + sinon.stub(poetryService, 'didTriggerInterpreterSuggestions').get(() => true); + }); + + teardown(() => { + sinon.restore(); + }); + + test(`Should return an empty list'${testSuffix}`, () => { + const environments = poetryService.getInterpreters(resource); + expect(environments).to.be.eventually.deep.equal([]); + }); + test(`Should return an empty list if there is no \'pyproject.toml\'${testSuffix}`, async () => { + const env = {}; + envVarsProvider + .setup((e) => e.getEnvironmentVariables(TypeMoq.It.isAny())) + .returns(() => Promise.resolve({})) + .verifiable(TypeMoq.Times.once()); + currentProcess.setup((c) => c.env).returns(() => env); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(path.join(rootWorkspace, 'pyproject.toml')))) + .returns(() => Promise.resolve(false)) + .verifiable(TypeMoq.Times.once()); + const environments = await poetryService.getInterpreters(resource); + + expect(environments).to.be.deep.equal([]); + fileSystem.verifyAll(); + }); + test(`Should display warning message if there is a \'pyproject.toml\' but \'poetry --help\' fails ${testSuffix}`, async () => { + const env = {}; + currentProcess.setup((c) => c.env).returns(() => env); + processService + .setup((p) => + p.exec(TypeMoq.It.isValue('poetry'), TypeMoq.It.isValue(['--help']), TypeMoq.It.isAny()) + ) + .returns(() => Promise.reject('')); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(path.join(rootWorkspace, 'pyproject.toml')))) + .returns(() => Promise.resolve(true)); + const warningMessage = + "Workspace contains pyproject.toml but 'poetry' was not found. Make sure 'poetry' is on the PATH."; + appShell + .setup((a) => a.showWarningMessage(warningMessage)) + .returns(() => Promise.resolve('')) + .verifiable(TypeMoq.Times.once()); + const environments = await poetryService.getInterpreters(resource); + + expect(environments).to.be.deep.equal([]); + appShell.verifyAll(); + }); + test(`Should return interpreter information${testSuffix}`, async () => { + const env = {}; + const pythonPath = 'one'; + envVarsProvider + .setup((e) => e.getEnvironmentVariables(TypeMoq.It.isAny())) + .returns(() => Promise.resolve({})) + .verifiable(TypeMoq.Times.once()); + currentProcess.setup((c) => c.env).returns(() => env); + processService + .setup((p) => p.exec(TypeMoq.It.isValue('poetry'), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => Promise.resolve({ stdout: pythonPath })); + interpreterHelper + .setup((v) => v.getInterpreterInformation(TypeMoq.It.isAny())) + .returns(() => Promise.resolve({ version: new SemVer('1.0.0') })); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(path.join(rootWorkspace, 'pyproject.toml')))) + .returns(() => Promise.resolve(true)) + .verifiable(); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(`${pythonPath}/bin/python`))) + .returns(() => Promise.resolve(true)) + .verifiable(); + + const environments = await poetryService.getInterpreters(resource); + + expect(environments).to.be.lengthOf(1); + fileSystem.verifyAll(); + }); + test("Must use 'python.poetryPath' setting", async () => { + poetryPathSetting = 'spam-spam-pipenv-spam-spam'; + const poetryExe = poetryService.executable; + assert.equal(poetryExe, 'spam-spam-pipenv-spam-spam', 'Failed to identify poetry.exe'); + }); + + test('Should send telemetry event when calling getInterpreters', async () => { + const sendTelemetryStub = sinon.stub(Telemetry, 'sendTelemetryEvent'); + + await poetryService.getInterpreters(resource); + + sinon.assert.calledWith(sendTelemetryStub, EventName.POETRY_INTERPRETER_DISCOVERY); + sinon.restore(); + }); + }); + + suite('With didTriggerInterpreterSuggestions set to false', () => { + setup(() => { + sinon.stub(poetryService, 'didTriggerInterpreterSuggestions').get(() => false); + }); + + teardown(() => { + sinon.restore(); + }); + + test('isRelatedPoetryEnvironment should exit early', async () => { + processService + .setup((p) => p.exec(TypeMoq.It.isValue('poetry'), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .verifiable(TypeMoq.Times.never()); + + const result = await poetryService.isRelatedPoetryEnvironment('foo', 'some/python/path'); + + expect(result).to.be.equal(false, 'isRelatedPoetryEnvironment should return false.'); + processService.verifyAll(); + }); + + test('Executable getter should return an empty string', () => { + const executable = poetryService.executable; + + expect(executable).to.be.equal('', 'The executable getter should return an empty string.'); + }); + + test('getInterpreters should exit early', async () => { + processService + .setup((p) => p.exec(TypeMoq.It.isValue('poetry'), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .verifiable(TypeMoq.Times.never()); + + const interpreters = await poetryService.getInterpreters(resource); + + expect(interpreters).to.be.lengthOf(0); + processService.verifyAll(); + }); + }); + }); + }); +}); diff --git a/src/test/interpreters/virtualEnvManager.unit.test.ts b/src/test/interpreters/virtualEnvManager.unit.test.ts index d4c3de8c7851..946ee60a4d19 100644 --- a/src/test/interpreters/virtualEnvManager.unit.test.ts +++ b/src/test/interpreters/virtualEnvManager.unit.test.ts @@ -10,7 +10,13 @@ import { Uri, WorkspaceFolder } from 'vscode'; import { IWorkspaceService } from '../../client/common/application/types'; import { IFileSystem } from '../../client/common/platform/types'; import { IProcessServiceFactory } from '../../client/common/process/types'; -import { IInterpreterLocatorService, IPipEnvService, PIPENV_SERVICE } from '../../client/interpreter/contracts'; +import { + IInterpreterLocatorService, + IPipEnvService, + IPoetryService, + PIPENV_SERVICE, + POETRY_SERVICE +} from '../../client/interpreter/contracts'; import { VirtualEnvironmentManager } from '../../client/interpreter/virtualEnvs'; import { IServiceContainer } from '../../client/ioc/types'; @@ -85,9 +91,16 @@ suite('Virtual environment manager', () => { pipEnvService .setup((w) => w.isRelatedPipEnvironment(TypeMoq.It.isAny(), TypeMoq.It.isAny())) .returns(() => Promise.resolve(isPipEnvironment)); + const poetryService = TypeMoq.Mock.ofType(); + poetryService + .setup((w) => w.isRelatedPoetryEnvironment(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => Promise.resolve(isPipEnvironment)); serviceContainer .setup((s) => s.get(TypeMoq.It.isValue(IInterpreterLocatorService), TypeMoq.It.isValue(PIPENV_SERVICE))) .returns(() => pipEnvService.object); + serviceContainer + .setup((s) => s.get(TypeMoq.It.isValue(IInterpreterLocatorService), TypeMoq.It.isValue(POETRY_SERVICE))) + .returns(() => poetryService.object); const workspaceService = TypeMoq.Mock.ofType(); workspaceService.setup((w) => w.hasWorkspaceFolders).returns(() => false); if (resource) { diff --git a/src/test/interpreters/virtualEnvs/index.unit.test.ts b/src/test/interpreters/virtualEnvs/index.unit.test.ts index 7913dbb7edae..b71e9de76e41 100644 --- a/src/test/interpreters/virtualEnvs/index.unit.test.ts +++ b/src/test/interpreters/virtualEnvs/index.unit.test.ts @@ -18,7 +18,9 @@ import { IInterpreterLocatorService, InterpreterType, IPipEnvService, - PIPENV_SERVICE + IPoetryService, + PIPENV_SERVICE, + POETRY_SERVICE } from '../../../client/interpreter/contracts'; import { VirtualEnvironmentManager } from '../../../client/interpreter/virtualEnvs'; import { IServiceContainer } from '../../../client/ioc/types'; @@ -32,6 +34,7 @@ suite('Virtual Environment Manager', () => { let fs: TypeMoq.IMock; let workspace: TypeMoq.IMock; let pipEnvService: TypeMoq.IMock; + let poetryService: TypeMoq.IMock; let terminalActivation: TypeMoq.IMock; let platformService: TypeMoq.IMock; @@ -44,6 +47,7 @@ suite('Virtual Environment Manager', () => { fs = TypeMoq.Mock.ofType(); workspace = TypeMoq.Mock.ofType(); pipEnvService = TypeMoq.Mock.ofType(); + poetryService = TypeMoq.Mock.ofType(); terminalActivation = TypeMoq.Mock.ofType(); platformService = TypeMoq.Mock.ofType(); @@ -59,6 +63,9 @@ suite('Virtual Environment Manager', () => { serviceContainer .setup((c) => c.get(TypeMoq.It.isValue(IInterpreterLocatorService), TypeMoq.It.isValue(PIPENV_SERVICE))) .returns(() => pipEnvService.object); + serviceContainer + .setup((c) => c.get(TypeMoq.It.isValue(IInterpreterLocatorService), TypeMoq.It.isValue(POETRY_SERVICE))) + .returns(() => poetryService.object); serviceContainer .setup((c) => c.get(TypeMoq.It.isValue(ITerminalActivationCommandProvider), TypeMoq.It.isAny())) .returns(() => terminalActivation.object); @@ -289,6 +296,7 @@ suite('Virtual Environment Manager', () => { test('Get Environment Type, does not detect the type', async () => { const pythonPath = path.join('x', 'b', 'c', 'python'); virtualEnvMgr.isPipEnvironment = () => Promise.resolve(false); + virtualEnvMgr.isPoetryEnvironment = () => Promise.resolve(false); virtualEnvMgr.isPyEnvEnvironment = () => Promise.resolve(false); virtualEnvMgr.isVenvEnvironment = () => Promise.resolve(false); virtualEnvMgr.isVirtualEnvironment = () => Promise.resolve(false);