From bebf05da6f5e7f29f3b41e4827743004612ca220 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 19 Jan 2023 11:37:44 -0800 Subject: [PATCH] Create env shows requirements files or `pyproject.toml` extras when available (#20524) Closes https://github.com/microsoft/vscode-python/issues/20277 Closes https://github.com/microsoft/vscode-python/issues/20278 --- package-lock.json | 11 + package.json | 1 + pythonFiles/create_venv.py | 83 +++-- pythonFiles/tests/test_create_venv.py | 114 ++++++- src/client/common/utils/localize.ts | 8 +- src/client/common/vscodeApis/workspaceApis.ts | 20 +- .../creation/provider/venvCreationProvider.ts | 116 ++++--- .../creation/provider/venvUtils.ts | 140 ++++++++ src/test/mocks/vsc/index.ts | 15 +- .../venvCreationProvider.unit.test.ts | 60 ++-- .../creation/provider/venvUtils.unit.test.ts | 313 ++++++++++++++++++ 11 files changed, 756 insertions(+), 125 deletions(-) create mode 100644 src/client/pythonEnvironments/creation/provider/venvUtils.ts create mode 100644 src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 63f798cb2d6e..c82d17d74e1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2023.1.0-dev", "license": "MIT", "dependencies": { + "@ltd/j-toml": "^1.37.0", "@vscode/extension-telemetry": "^0.7.4-preview", "@vscode/jupyter-lsp-middleware": "^0.2.50", "arch": "^2.1.0", @@ -586,6 +587,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@ltd/j-toml": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.37.0.tgz", + "integrity": "sha512-VN6aOzc57LPBxnySMRX+7YHt7IsZsYiBzEoi2z2LdedztOHK/gqvM2i1OpJhrOXxPI8djCF09z1F8ZN6+EEY5Q==" + }, "node_modules/@microsoft/1ds-core-js": { "version": "3.2.8", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-3.2.8.tgz", @@ -15823,6 +15829,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@ltd/j-toml": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.37.0.tgz", + "integrity": "sha512-VN6aOzc57LPBxnySMRX+7YHt7IsZsYiBzEoi2z2LdedztOHK/gqvM2i1OpJhrOXxPI8djCF09z1F8ZN6+EEY5Q==" + }, "@microsoft/1ds-core-js": { "version": "3.2.8", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-3.2.8.tgz", diff --git a/package.json b/package.json index b29435cbf71a..b820542b7728 100644 --- a/package.json +++ b/package.json @@ -1806,6 +1806,7 @@ "webpack": "webpack" }, "dependencies": { + "@ltd/j-toml": "^1.37.0", "@vscode/extension-telemetry": "^0.7.4-preview", "@vscode/jupyter-lsp-middleware": "^0.2.50", "arch": "^2.1.0", diff --git a/pythonFiles/create_venv.py b/pythonFiles/create_venv.py index 1f31abc5cc87..24d4baa357c9 100644 --- a/pythonFiles/create_venv.py +++ b/pythonFiles/create_venv.py @@ -7,7 +7,7 @@ import pathlib import subprocess import sys -from typing import Optional, Sequence, Union +from typing import List, Optional, Sequence, Union VENV_NAME = ".venv" CWD = pathlib.PurePath(os.getcwd()) @@ -19,12 +19,27 @@ class VenvError(Exception): def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser = argparse.ArgumentParser() + parser.add_argument( - "--install", - action="store_true", - default=False, - help="Install packages into the virtual environment.", + "--requirements", + action="append", + default=[], + help="Install additional dependencies into the virtual environment.", + ) + + parser.add_argument( + "--toml", + action="store", + default=None, + help="Install additional dependencies from sources like `pyproject.toml` into the virtual environment.", ) + parser.add_argument( + "--extras", + action="append", + default=[], + help="Install specific package groups from `pyproject.toml` into the virtual environment.", + ) + parser.add_argument( "--git-ignore", action="store_true", @@ -71,30 +86,36 @@ def get_venv_path(name: str) -> str: return os.fspath(CWD / name / "bin" / "python") -def install_packages(venv_path: str) -> None: - requirements = os.fspath(CWD / "requirements.txt") - pyproject = os.fspath(CWD / "pyproject.toml") +def install_requirements(venv_path: str, requirements: List[str]) -> None: + if not requirements: + return + print(f"VENV_INSTALLING_REQUIREMENTS: {requirements}") + args = [] + for requirement in requirements: + args += ["-r", requirement] + run_process( + [venv_path, "-m", "pip", "install"] + args, + "CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS", + ) + print("CREATE_VENV.PIP_INSTALLED_REQUIREMENTS") + + +def install_toml(venv_path: str, extras: List[str]) -> None: + args = "." if len(extras) == 0 else f".[{','.join(extras)}]" + run_process( + [venv_path, "-m", "pip", "install", "-e", args], + "CREATE_VENV.PIP_FAILED_INSTALL_PYPROJECT", + ) + print("CREATE_VENV.PIP_INSTALLED_PYPROJECT") + + +def upgrade_pip(venv_path: str) -> None: run_process( [venv_path, "-m", "pip", "install", "--upgrade", "pip"], "CREATE_VENV.PIP_UPGRADE_FAILED", ) - if file_exists(requirements): - print(f"VENV_INSTALLING_REQUIREMENTS: {requirements}") - run_process( - [venv_path, "-m", "pip", "install", "-r", requirements], - "CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS", - ) - print("CREATE_VENV.PIP_INSTALLED_REQUIREMENTS") - elif file_exists(pyproject): - print(f"VENV_INSTALLING_PYPROJECT: {pyproject}") - run_process( - [venv_path, "-m", "pip", "install", "-e", ".[extras]"], - "CREATE_VENV.PIP_FAILED_INSTALL_PYPROJECT", - ) - print("CREATE_VENV.PIP_INSTALLED_PYPROJECT") - def add_gitignore(name: str) -> None: git_ignore = CWD / name / ".gitignore" @@ -112,7 +133,9 @@ def main(argv: Optional[Sequence[str]] = None) -> None: if not is_installed("venv"): raise VenvError("CREATE_VENV.VENV_NOT_FOUND") - if args.install and not is_installed("pip"): + pip_installed = is_installed("pip") + deps_needed = args.requirements or args.extras or args.toml + if deps_needed and not pip_installed: raise VenvError("CREATE_VENV.PIP_NOT_FOUND") if venv_exists(args.name): @@ -128,8 +151,16 @@ def main(argv: Optional[Sequence[str]] = None) -> None: if args.git_ignore: add_gitignore(args.name) - if args.install: - install_packages(venv_path) + if pip_installed: + upgrade_pip(venv_path) + + if args.requirements: + print(f"VENV_INSTALLING_REQUIREMENTS: {args.requirements}") + install_requirements(venv_path, args.requirements) + + if args.toml: + print(f"VENV_INSTALLING_PYPROJECT: {args.toml}") + install_toml(venv_path, args.extras) if __name__ == "__main__": diff --git a/pythonFiles/tests/test_create_venv.py b/pythonFiles/tests/test_create_venv.py index e002ad17ef95..e70a4d90c99b 100644 --- a/pythonFiles/tests/test_create_venv.py +++ b/pythonFiles/tests/test_create_venv.py @@ -16,38 +16,44 @@ def test_venv_not_installed(): assert str(e.value) == "CREATE_VENV.VENV_NOT_FOUND" -def test_pip_not_installed(): +@pytest.mark.parametrize("install", ["requirements", "toml"]) +def test_pip_not_installed(install): importlib.reload(create_venv) create_venv.venv_exists = lambda _n: True create_venv.is_installed = lambda module: module != "pip" create_venv.run_process = lambda _args, _error_message: None with pytest.raises(create_venv.VenvError) as e: - create_venv.main(["--install"]) + if install == "requirements": + create_venv.main(["--requirements", "requirements-for-test.txt"]) + elif install == "toml": + create_venv.main(["--toml", "pyproject.toml", "--extras", "test"]) assert str(e.value) == "CREATE_VENV.PIP_NOT_FOUND" -@pytest.mark.parametrize("env_exists", [True, False]) -@pytest.mark.parametrize("git_ignore", [True, False]) -@pytest.mark.parametrize("install", [True, False]) +@pytest.mark.parametrize("env_exists", ["hasEnv", "noEnv"]) +@pytest.mark.parametrize("git_ignore", ["useGitIgnore", "skipGitIgnore"]) +@pytest.mark.parametrize("install", ["requirements", "toml", "skipInstall"]) def test_create_env(env_exists, git_ignore, install): importlib.reload(create_venv) create_venv.is_installed = lambda _x: True - create_venv.venv_exists = lambda _n: env_exists + create_venv.venv_exists = lambda _n: env_exists == "hasEnv" + create_venv.upgrade_pip = lambda _x: None install_packages_called = False - def install_packages(_name): + def install_packages(_env, _name): nonlocal install_packages_called install_packages_called = True - create_venv.install_packages = install_packages + create_venv.install_requirements = install_packages + create_venv.install_toml = install_packages run_process_called = False def run_process(args, error_message): nonlocal run_process_called run_process_called = True - if not env_exists: + if env_exists == "noEnv": assert args == [sys.executable, "-m", "venv", create_venv.VENV_NAME] assert error_message == "CREATE_VENV.VENV_FAILED_CREATION" @@ -62,18 +68,23 @@ def add_gitignore(_name): create_venv.add_gitignore = add_gitignore args = [] - if git_ignore: - args.append("--git-ignore") - if install: - args.append("--install") + if git_ignore == "useGitIgnore": + args += ["--git-ignore"] + if install == "requirements": + args += ["--requirements", "requirements-for-test.txt"] + elif install == "toml": + args += ["--toml", "pyproject.toml", "--extras", "test"] + create_venv.main(args) - assert install_packages_called == install + assert install_packages_called == (install != "skipInstall") # run_process is called when the venv does not exist - assert run_process_called != env_exists + assert run_process_called == (env_exists == "noEnv") # add_gitignore is called when new venv is created and git_ignore is True - assert add_gitignore_called == (not env_exists and git_ignore) + assert add_gitignore_called == ( + (env_exists == "noEnv") and (git_ignore == "useGitIgnore") + ) @pytest.mark.parametrize("install_type", ["requirements", "pyproject"]) @@ -93,12 +104,79 @@ def run_process(args, error_message): elif args[1:-1] == ["-m", "pip", "install", "-r"]: installing = "requirements" assert error_message == "CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS" - elif args[1:] == ["-m", "pip", "install", "-e", ".[extras]"]: + elif args[1:] == ["-m", "pip", "install", "-e", ".[test]"]: installing = "pyproject" assert error_message == "CREATE_VENV.PIP_FAILED_INSTALL_PYPROJECT" create_venv.run_process = run_process - create_venv.main(["--install"]) + if install_type == "requirements": + create_venv.main(["--requirements", "requirements-for-test.txt"]) + elif install_type == "pyproject": + create_venv.main(["--toml", "pyproject.toml", "--extras", "test"]) + assert pip_upgraded assert installing == install_type + + +@pytest.mark.parametrize( + ("extras", "expected"), + [ + ([], ["-m", "pip", "install", "-e", "."]), + (["test"], ["-m", "pip", "install", "-e", ".[test]"]), + (["test", "doc"], ["-m", "pip", "install", "-e", ".[test,doc]"]), + ], +) +def test_toml_args(extras, expected): + importlib.reload(create_venv) + + actual = [] + + def run_process(args, error_message): + nonlocal actual + actual = args[1:] + + create_venv.run_process = run_process + + create_venv.install_toml(sys.executable, extras) + + assert actual == expected + + +@pytest.mark.parametrize( + ("extras", "expected"), + [ + ([], None), + ( + ["requirements/test.txt"], + [sys.executable, "-m", "pip", "install", "-r", "requirements/test.txt"], + ), + ( + ["requirements/test.txt", "requirements/doc.txt"], + [ + sys.executable, + "-m", + "pip", + "install", + "-r", + "requirements/test.txt", + "-r", + "requirements/doc.txt", + ], + ), + ], +) +def test_requirements_args(extras, expected): + importlib.reload(create_venv) + + actual = None + + def run_process(args, error_message): + nonlocal actual + actual = args + + create_venv.run_process = run_process + + create_venv.install_requirements(sys.executable, extras) + + assert actual == expected diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 9928a15194e7..09d5d7c23fe5 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -437,6 +437,8 @@ export namespace CreateEnv { export const selectPythonQuickPickTitle = l10n.t('Select a python to use for environment creation'); export const providerDescription = l10n.t('Creates a `.venv` virtual environment in the current workspace'); export const error = l10n.t('Creating virtual environment failed with error.'); + export const tomlExtrasQuickPickTitle = l10n.t('Select optional dependencies to install from pyproject.toml'); + export const requirementsQuickPickTitle = l10n.t('Select dependencies to install'); } export namespace Conda { @@ -454,13 +456,11 @@ export namespace CreateEnv { export namespace ToolsExtensions { export const flake8PromptMessage = l10n.t( - 'toolsExt.flake8.message', 'Use the Flake8 extension to enable easier configuration and new features such as quick fixes.', ); export const pylintPromptMessage = l10n.t( - 'toolsExt.pylint.message', 'Use the Pylint extension to enable easier configuration and new features such as quick fixes.', ); - export const installPylintExtension = l10n.t('toolsExt.install.pylint', 'Install Pylint extension'); - export const installFlake8Extension = l10n.t('toolsExt.install.flake8', 'Install Flake8 extension'); + export const installPylintExtension = l10n.t('Install Pylint extension'); + export const installFlake8Extension = l10n.t('Install Flake8 extension'); } diff --git a/src/client/common/vscodeApis/workspaceApis.ts b/src/client/common/vscodeApis/workspaceApis.ts index e7320e7e0e61..fda05e2477af 100644 --- a/src/client/common/vscodeApis/workspaceApis.ts +++ b/src/client/common/vscodeApis/workspaceApis.ts @@ -1,7 +1,16 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ConfigurationScope, workspace, WorkspaceConfiguration, WorkspaceEdit, WorkspaceFolder } from 'vscode'; +import { + CancellationToken, + ConfigurationScope, + GlobPattern, + Uri, + workspace, + WorkspaceConfiguration, + WorkspaceEdit, + WorkspaceFolder, +} from 'vscode'; import { Resource } from '../types'; export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { @@ -23,3 +32,12 @@ export function getConfiguration(section?: string, scope?: ConfigurationScope | export function applyEdit(edit: WorkspaceEdit): Thenable { return workspace.applyEdit(edit); } + +export function findFiles( + include: GlobPattern, + exclude?: GlobPattern | null, + maxResults?: number, + token?: CancellationToken, +): Thenable { + return workspace.findFiles(include, exclude, maxResults, token); +} diff --git a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts index cf0ac950410c..3bc927d898e9 100644 --- a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -8,7 +8,7 @@ import { createVenvScript } from '../../../common/process/internal/scripts'; import { execObservable } from '../../../common/process/rawProcessApis'; import { createDeferred } from '../../../common/utils/async'; import { Common, CreateEnv } from '../../../common/utils/localize'; -import { traceError, traceLog } from '../../../logging'; +import { traceError, traceInfo, traceLog } from '../../../logging'; import { CreateEnvironmentOptions, CreateEnvironmentProgress, @@ -23,23 +23,22 @@ import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; import { VenvProgressAndTelemetry, VENV_CREATED_MARKER, VENV_EXISTING_MARKER } from './venvProgressAndTelemetry'; import { showErrorMessageWithLogs } from '../common/commonUtils'; +import { IPackageInstallSelection, pickPackagesToInstall } from './venvUtils'; -function generateCommandArgs(options?: CreateEnvironmentOptions): string[] { - let addGitIgnore = true; - let installPackages = true; - if (options) { - addGitIgnore = options?.ignoreSourceControl !== undefined ? options.ignoreSourceControl : true; - installPackages = options?.installPackages !== undefined ? options.installPackages : true; - } - +function generateCommandArgs(installInfo?: IPackageInstallSelection, addGitIgnore?: boolean): string[] { const command: string[] = [createVenvScript()]; if (addGitIgnore) { command.push('--git-ignore'); } - if (installPackages) { - command.push('--install'); + if (installInfo) { + if (installInfo?.installType === 'toml') { + command.push('--toml', installInfo.source?.fileToCommandArgumentForPythonExt() || 'pyproject.toml'); + installInfo.installList?.forEach((i) => command.push('--extras', i)); + } else if (installInfo?.installType === 'requirements') { + installInfo.installList?.forEach((i) => command.push('--requirements', i)); + } } return command; @@ -128,51 +127,62 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { [EnvironmentType.System, EnvironmentType.MicrosoftStore, EnvironmentType.Global].includes(i.envType), ); - if (interpreter) { - return withProgress( - { - location: ProgressLocation.Notification, - title: `${CreateEnv.statusTitle} ([${Common.showLogs}](command:${Commands.ViewOutput}))`, - cancellable: true, - }, - async ( - progress: CreateEnvironmentProgress, - token: CancellationToken, - ): Promise => { - let hasError = false; - - progress.report({ - message: CreateEnv.statusStarting, - }); - - let envPath: string | undefined; - try { - if (interpreter) { - envPath = await createVenv( - workspace, - interpreter, - generateCommandArgs(options), - progress, - token, - ); - } - } catch (ex) { - traceError(ex); - hasError = true; - throw ex; - } finally { - if (hasError) { - showErrorMessageWithLogs(CreateEnv.Venv.errorCreatingEnvironment); - } - } + let addGitIgnore = true; + let installPackages = true; + if (options) { + addGitIgnore = options?.ignoreSourceControl !== undefined ? options.ignoreSourceControl : true; + installPackages = options?.installPackages !== undefined ? options.installPackages : true; + } + let installInfo: IPackageInstallSelection | undefined; + if (installPackages) { + installInfo = await pickPackagesToInstall(workspace); + } + const args = generateCommandArgs(installInfo, addGitIgnore); - return { path: envPath, uri: workspace.uri }; - }, - ); + if (!interpreter) { + traceError('Virtual env creation requires an interpreter.'); + return undefined; } - traceError('Virtual env creation requires an interpreter.'); - return undefined; + if (!installInfo) { + traceInfo('Virtual env creation exited during dependencies selection.'); + return undefined; + } + + return withProgress( + { + location: ProgressLocation.Notification, + title: `${CreateEnv.statusTitle} ([${Common.showLogs}](command:${Commands.ViewOutput}))`, + cancellable: true, + }, + async ( + progress: CreateEnvironmentProgress, + token: CancellationToken, + ): Promise => { + let hasError = false; + + progress.report({ + message: CreateEnv.statusStarting, + }); + + let envPath: string | undefined; + try { + if (interpreter) { + envPath = await createVenv(workspace, interpreter, args, progress, token); + } + } catch (ex) { + traceError(ex); + hasError = true; + throw ex; + } finally { + if (hasError) { + showErrorMessageWithLogs(CreateEnv.Venv.errorCreatingEnvironment); + } + } + + return { path: envPath, uri: workspace.uri }; + }, + ); } name = 'Venv'; diff --git a/src/client/pythonEnvironments/creation/provider/venvUtils.ts b/src/client/pythonEnvironments/creation/provider/venvUtils.ts new file mode 100644 index 000000000000..e7d7124a20ca --- /dev/null +++ b/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import * as tomljs from '@ltd/j-toml'; +import * as fs from 'fs-extra'; +import { flatten, isArray } from 'lodash'; +import * as path from 'path'; +import { CancellationToken, QuickPickItem, RelativePattern, WorkspaceFolder } from 'vscode'; +import { CreateEnv } from '../../../common/utils/localize'; +import { showQuickPick } from '../../../common/vscodeApis/windowApis'; +import { findFiles } from '../../../common/vscodeApis/workspaceApis'; +import { traceError, traceVerbose } from '../../../logging'; + +const exclude = '**/{.venv*,.git,.nox,.tox,.conda}/**'; +async function getPipRequirementsFiles( + workspaceFolder: WorkspaceFolder, + token?: CancellationToken, +): Promise { + const files = flatten( + await Promise.all([ + findFiles(new RelativePattern(workspaceFolder, '**/*requirement*.txt'), exclude, undefined, token), + findFiles(new RelativePattern(workspaceFolder, '**/requirements/*.txt'), exclude, undefined, token), + ]), + ).map((u) => u.fsPath); + return files; +} + +async function getTomlOptionalDeps(tomlPath: string): Promise { + if (await fs.pathExists(tomlPath)) { + const content = await fs.readFile(tomlPath, 'utf-8'); + const extras: string[] = []; + try { + const toml = tomljs.parse(content); + if (toml.project && (toml.project as Record>)['optional-dependencies']) { + const deps = (toml.project as Record>>)['optional-dependencies']; + for (const key of Object.keys(deps)) { + extras.push(key); + } + } + } catch (err) { + traceError('Failed to parse `pyproject.toml`:', err); + } + return extras; + } + return undefined; +} + +async function pickTomlExtras(extras: string[], token?: CancellationToken): Promise { + const items: QuickPickItem[] = extras.map((e) => ({ label: e })); + + const selection = await showQuickPick( + items, + { + placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, + canPickMany: true, + ignoreFocusOut: true, + }, + token, + ); + + if (selection && isArray(selection)) { + return selection.map((s) => s.label); + } + + return undefined; +} + +async function pickRequirementsFiles(files: string[], token?: CancellationToken): Promise { + const items: QuickPickItem[] = files + .sort((a, b) => { + const al = a.split(/[\\\/]/).length; + const bl = b.split(/[\\\/]/).length; + if (al === bl) { + if (a.length === b.length) { + return a.localeCompare(b); + } + return a.length - b.length; + } + return al - bl; + }) + .map((e) => ({ label: e })); + + const selection = await showQuickPick( + items, + { + placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + token, + ); + + if (selection && isArray(selection)) { + return selection.map((s) => s.label); + } + + return undefined; +} + +export interface IPackageInstallSelection { + installType: 'toml' | 'requirements' | 'none'; + installList: string[]; + source?: string; +} + +export async function pickPackagesToInstall( + workspaceFolder: WorkspaceFolder, + token?: CancellationToken, +): Promise { + const tomlPath = path.join(workspaceFolder.uri.fsPath, 'pyproject.toml'); + traceVerbose(`Looking for toml pyproject.toml with optional dependencies at: ${tomlPath}`); + const extras = await getTomlOptionalDeps(tomlPath); + + if (extras && extras.length > 0) { + traceVerbose('Found toml with optional dependencies.'); + const installList = await pickTomlExtras(extras, token); + if (installList) { + return { installType: 'toml', installList, source: tomlPath }; + } + return undefined; + } + + traceVerbose('Looking for pip requirements.'); + const requirementFiles = (await getPipRequirementsFiles(workspaceFolder, token))?.map((p) => + path.relative(workspaceFolder.uri.fsPath, p), + ); + + if (requirementFiles && requirementFiles.length > 0) { + traceVerbose('Found pip requirements.'); + const installList = (await pickRequirementsFiles(requirementFiles, token))?.map((p) => + path.join(workspaceFolder.uri.fsPath, p), + ); + if (installList) { + return { installType: 'requirements', installList }; + } + return undefined; + } + + return { installType: 'none', installList: [] }; +} diff --git a/src/test/mocks/vsc/index.ts b/src/test/mocks/vsc/index.ts index 986e81d85f3f..e6ea57a88673 100644 --- a/src/test/mocks/vsc/index.ts +++ b/src/test/mocks/vsc/index.ts @@ -81,10 +81,19 @@ export namespace l10n { }, ...args: unknown[] ): string { - if (args) { - return (message as string).format(...(args as Array)) as string; + let _message = message; + let _args: unknown[] | Record | undefined = args; + if (typeof message !== 'string') { + _message = message.message; + _args = message.args ?? args; } - return message as string; + + if ((_args as Array).length > 0) { + return (_message as string).replace(/{(\d+)}/g, (match, number) => + (_args as Array)[number] === undefined ? match : (_args as Array)[number], + ); + } + return _message as string; } export const bundle: { [key: string]: string } | undefined = undefined; export const uri: vscode.Uri | undefined = undefined; diff --git a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts index 6b6187ed3e4a..856733b29a1c 100644 --- a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts @@ -22,6 +22,7 @@ import { createDeferred } from '../../../../client/common/utils/async'; import { Output } from '../../../../client/common/process/types'; import { VENV_CREATED_MARKER } from '../../../../client/pythonEnvironments/creation/provider/venvProgressAndTelemetry'; import { CreateEnv } from '../../../../client/common/utils/localize'; +import * as venvUtils from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; chaiUse(chaiAsPromised); @@ -33,12 +34,20 @@ suite('venv Creation provider tests', () => { let execObservableStub: sinon.SinonStub; let withProgressStub: sinon.SinonStub; let showErrorMessageWithLogsStub: sinon.SinonStub; + let pickPackagesToInstallStub: sinon.SinonStub; + + const workspace1 = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; setup(() => { pickWorkspaceFolderStub = sinon.stub(wsSelect, 'pickWorkspaceFolder'); execObservableStub = sinon.stub(rawProcessApis, 'execObservable'); interpreterQuickPick = typemoq.Mock.ofType(); withProgressStub = sinon.stub(windowApis, 'withProgress'); + pickPackagesToInstallStub = sinon.stub(venvUtils, 'pickPackagesToInstall'); showErrorMessageWithLogsStub = sinon.stub(commonUtils, 'showErrorMessageWithLogs'); showErrorMessageWithLogsStub.resolves(); @@ -59,11 +68,7 @@ suite('venv Creation provider tests', () => { }); test('No Python selected', async () => { - pickWorkspaceFolderStub.resolves({ - uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), - name: 'workspace1', - index: 0, - }); + pickWorkspaceFolderStub.resolves(workspace1); interpreterQuickPick .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) @@ -74,12 +79,21 @@ suite('venv Creation provider tests', () => { interpreterQuickPick.verifyAll(); }); + test('User pressed Esc while selecting dependencies', async () => { + pickWorkspaceFolderStub.resolves(workspace1); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve('/usr/bin/python')) + .verifiable(typemoq.Times.once()); + + pickPackagesToInstallStub.resolves(undefined); + + assert.isUndefined(await venvProvider.createEnvironment()); + assert.isTrue(pickPackagesToInstallStub.calledOnce); + }); + test('Create venv with python selected by user', async () => { - const workspace1 = { - uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), - name: 'workspace1', - index: 0, - }; pickWorkspaceFolderStub.resolves(workspace1); interpreterQuickPick @@ -87,6 +101,11 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); + pickPackagesToInstallStub.resolves({ + installType: 'none', + installList: [], + }); + const deferred = createDeferred(); let _next: undefined | ((value: Output) => void); let _complete: undefined | (() => void); @@ -138,17 +157,18 @@ suite('venv Creation provider tests', () => { }); test('Create venv failed', async () => { - pickWorkspaceFolderStub.resolves({ - uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), - name: 'workspace1', - index: 0, - }); + pickWorkspaceFolderStub.resolves(workspace1); interpreterQuickPick .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); + pickPackagesToInstallStub.resolves({ + installType: 'none', + installList: [], + }); + const deferred = createDeferred(); let _error: undefined | ((error: unknown) => void); let _complete: undefined | (() => void); @@ -194,11 +214,6 @@ suite('venv Creation provider tests', () => { }); test('Create venv failed (non-zero exit code)', async () => { - const workspace1 = { - uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), - name: 'workspace1', - index: 0, - }; pickWorkspaceFolderStub.resolves(workspace1); interpreterQuickPick @@ -206,6 +221,11 @@ suite('venv Creation provider tests', () => { .returns(() => Promise.resolve('/usr/bin/python')) .verifiable(typemoq.Times.once()); + pickPackagesToInstallStub.resolves({ + installType: 'none', + installList: [], + }); + const deferred = createDeferred(); let _next: undefined | ((value: Output) => void); let _complete: undefined | (() => void); diff --git a/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts new file mode 100644 index 000000000000..34ae41993da0 --- /dev/null +++ b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { assert } from 'chai'; +import * as fs from 'fs-extra'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as path from 'path'; +import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; +import { pickPackagesToInstall } from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; +import { CreateEnv } from '../../../../client/common/utils/localize'; + +suite('Venv Utils test', () => { + let findFilesStub: sinon.SinonStub; + let showQuickPickStub: sinon.SinonStub; + let pathExistsStub: sinon.SinonStub; + let readFileStub: sinon.SinonStub; + + const workspace1 = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + setup(() => { + findFilesStub = sinon.stub(workspaceApis, 'findFiles'); + showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + pathExistsStub = sinon.stub(fs, 'pathExists'); + readFileStub = sinon.stub(fs, 'readFile'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No requirements or toml found', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(false); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue(showQuickPickStub.notCalled); + assert.deepStrictEqual(actual, { + installType: 'none', + installList: [], + }); + }); + + test('Toml found with no optional deps', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(true); + readFileStub.resolves('[project]\nname = "spam"\nversion = "2020.0.0"\n'); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue(showQuickPickStub.notCalled); + assert.deepStrictEqual(actual, { + installType: 'none', + installList: [], + }); + }); + + test('Toml found with deps, but user presses escape', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(true); + readFileStub.resolves( + '[project]\nname = "spam"\nversion = "2020.0.0"\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + + showQuickPickStub.resolves(undefined); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'test' }, { label: 'doc' }], + { + placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, undefined); + }); + + test('Toml found with dependencies and user selects None', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(true); + readFileStub.resolves( + '[project]\nname = "spam"\nversion = "2020.0.0"\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + + showQuickPickStub.resolves([]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'test' }, { label: 'doc' }], + { + placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'toml', + installList: [], + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }); + }); + + test('Toml found with dependencies and user selects One', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(true); + readFileStub.resolves( + '[project]\nname = "spam"\nversion = "2020.0.0"\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + + showQuickPickStub.resolves([{ label: 'doc' }]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'test' }, { label: 'doc' }], + { + placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'toml', + installList: ['doc'], + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }); + }); + + test('Toml found with dependencies and user selects Few', async () => { + findFilesStub.resolves([]); + pathExistsStub.resolves(true); + readFileStub.resolves( + '[project]\nname = "spam"\nversion = "2020.0.0"\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]\ncov = ["pytest-cov"]', + ); + + showQuickPickStub.resolves([{ label: 'test' }, { label: 'cov' }]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'test' }, { label: 'doc' }, { label: 'cov' }], + { + placeHolder: CreateEnv.Venv.tomlExtrasQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'toml', + installList: ['test', 'cov'], + source: path.join(workspace1.uri.fsPath, 'pyproject.toml'), + }); + }); + + test('Requirements found, but user presses escape', async () => { + let allow = true; + findFilesStub.callsFake(() => { + if (allow) { + allow = false; + return Promise.resolve([ + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'dev-requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'test-requirements.txt')), + ]); + } + return Promise.resolve([]); + }); + pathExistsStub.resolves(false); + + showQuickPickStub.resolves(undefined); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + { + placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, undefined); + assert.isTrue(readFileStub.notCalled); + }); + + test('Requirements found and user selects None', async () => { + let allow = true; + findFilesStub.callsFake(() => { + if (allow) { + allow = false; + return Promise.resolve([ + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'dev-requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'test-requirements.txt')), + ]); + } + return Promise.resolve([]); + }); + pathExistsStub.resolves(false); + + showQuickPickStub.resolves([]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + { + placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'requirements', + installList: [], + }); + assert.isTrue(readFileStub.notCalled); + }); + + test('Requirements found and user selects One', async () => { + let allow = true; + findFilesStub.callsFake(() => { + if (allow) { + allow = false; + return Promise.resolve([ + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'dev-requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'test-requirements.txt')), + ]); + } + return Promise.resolve([]); + }); + pathExistsStub.resolves(false); + + showQuickPickStub.resolves([{ label: 'requirements.txt' }]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + { + placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'requirements', + installList: [path.join(workspace1.uri.fsPath, 'requirements.txt')], + }); + assert.isTrue(readFileStub.notCalled); + }); + + test('Requirements found and user selects Few', async () => { + let allow = true; + findFilesStub.callsFake(() => { + if (allow) { + allow = false; + return Promise.resolve([ + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'dev-requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'test-requirements.txt')), + ]); + } + return Promise.resolve([]); + }); + pathExistsStub.resolves(false); + + showQuickPickStub.resolves([{ label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }]); + + const actual = await pickPackagesToInstall(workspace1); + assert.isTrue( + showQuickPickStub.calledWithExactly( + [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + { + placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, + ignoreFocusOut: true, + canPickMany: true, + }, + undefined, + ), + ); + assert.deepStrictEqual(actual, { + installType: 'requirements', + installList: [ + path.join(workspace1.uri.fsPath, 'dev-requirements.txt'), + path.join(workspace1.uri.fsPath, 'test-requirements.txt'), + ], + }); + assert.isTrue(readFileStub.notCalled); + }); +});