diff --git a/package.json b/package.json index 20a39d429eb9..1d7a09308eed 100644 --- a/package.json +++ b/package.json @@ -271,6 +271,11 @@ "command": "python.createTerminal", "title": "%python.command.python.createTerminal.title%" }, + { + "category": "Python", + "command": "python.createEnvironment", + "title": "%python.command.python.createEnvironment.title%" + }, { "category": "Python", "command": "python.enableLinting", @@ -1540,6 +1545,12 @@ "title": "%python.command.python.configureTests.title%", "when": "!virtualWorkspace && shellExecutionSupported" }, + { + "category": "Python", + "command": "python.createEnvironment", + "title": "%python.command.python.createEnvironment.title%", + "when": "!virtualWorkspace && shellExecutionSupported" + }, { "category": "Python", "command": "python.createTerminal", diff --git a/package.nls.json b/package.nls.json index 611c98ed85e2..7a39d545fab6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,6 +1,7 @@ { "python.command.python.sortImports.title": "Sort Imports", "python.command.python.startREPL.title": "Start REPL", + "python.command.python.createEnvironment.title": "Create Environment", "python.command.python.createTerminal.title": "Create Terminal", "python.command.python.execInTerminal.title": "Run Python File in Terminal", "python.command.python.debugInTerminal.title": "Debug Python File", @@ -24,7 +25,7 @@ "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", "python.menu.createNewFile.title": "Python File", - "python.autoComplete.extraPaths.description":"List of paths to libraries and the like that need to be imported by auto complete engine. E.g. when using Google App SDK, the paths are not in system path, hence need to be added into this list.", + "python.autoComplete.extraPaths.description": "List of paths to libraries and the like that need to be imported by auto complete engine. E.g. when using Google App SDK, the paths are not in system path, hence need to be added into this list.", "python.condaPath.description": "Path to the conda executable to use for activation (version 4.4+).", "python.defaultInterpreterPath.description": "Path to default Python to use when extension loads up for the first time, no longer used once an interpreter is selected for the workspace. See https://aka.ms/AAfekmf to understand when this is used", "python.diagnostics.sourceMapsEnabled.description": "Enable source map support for meaningful stack traces in error logs.", @@ -32,7 +33,7 @@ "python.experiments.enabled.description": "Enables A/B tests experiments in the Python extension. If enabled, you may get included in proposed enhancements and/or features.", "python.experiments.optInto.description": "List of experiment to opt into. If empty, user is assigned the default experiment groups. See https://github.com/microsoft/vscode-python/wiki/Experiments for more details.", "python.experiments.optOutFrom.description": "List of experiment to opt out of. If empty, user is assigned the default experiment groups. See https://github.com/microsoft/vscode-python/wiki/Experiments for more details.", - "python.formatting.autopep8Args.description":"Arguments passed in. Each argument is a separate item in the array.", + "python.formatting.autopep8Args.description": "Arguments passed in. Each argument is a separate item in the array.", "python.formatting.autopep8Path.description": "Path to autopep8, you can use a custom version of autopep8 by modifying this setting to include the full path.", "python.formatting.blackArgs.description": "Arguments passed in. Each argument is a separate item in the array.", "python.formatting.blackPath.description": "Path to Black, you can use a custom version of Black by modifying this setting to include the full path.", diff --git a/pythonFiles/create_conda.py b/pythonFiles/create_conda.py new file mode 100644 index 000000000000..0e48ee6b2286 --- /dev/null +++ b/pythonFiles/create_conda.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import os +import pathlib +import subprocess +import sys +from typing import Optional, Sequence, Union + +CONDA_ENV_NAME = ".conda" +CWD = pathlib.PurePath(os.getcwd()) + + +class VenvError(Exception): + pass + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--python", + action="store", + help="Python version to install in the virtual environment.", + default=f"{sys.version_info.major}.{sys.version_info.minor}", + ) + parser.add_argument( + "--install", + action="store_true", + default=False, + help="Install packages into the virtual environment.", + ) + parser.add_argument( + "--git-ignore", + action="store_true", + default=False, + help="Add .gitignore to the newly created virtual environment.", + ) + parser.add_argument( + "--name", + default=CONDA_ENV_NAME, + type=str, + help="Name of the virtual environment.", + metavar="NAME", + action="store", + ) + return parser.parse_args(argv) + + +def file_exists(path: Union[str, pathlib.PurePath]) -> bool: + return os.path.exists(path) + + +def conda_env_exists(name: Union[str, pathlib.PurePath]) -> bool: + return os.path.exists(CWD / name) + + +def run_process(args: Sequence[str], error_message: str) -> None: + try: + print("Running: " + " ".join(args)) + subprocess.run(args, cwd=os.getcwd(), check=True) + except subprocess.CalledProcessError: + raise VenvError(error_message) + + +def get_conda_env_path(name: str) -> str: + return os.fspath(CWD / name) + + +def install_packages(env_path: str) -> None: + yml = os.fspath(CWD / "environment.yml") + if file_exists(yml): + print(f"CONDA_INSTALLING_YML: {yml}") + run_process( + [ + sys.executable, + "-m", + "conda", + "env", + "update", + "--prefix", + env_path, + "--file", + yml, + ], + "CREATE_CONDA.FAILED_INSTALL_YML", + ) + + +def add_gitignore(name: str) -> None: + git_ignore = os.fspath(CWD / name / ".gitignore") + if not file_exists(git_ignore): + print(f"Creating: {git_ignore}") + with open(git_ignore, "w") as f: + f.write("*") + + +def main(argv: Optional[Sequence[str]] = None) -> None: + if argv is None: + argv = [] + args = parse_args(argv) + + if not conda_env_exists(args.name): + run_process( + [ + sys.executable, + "-m", + "conda", + "create", + "--yes", + "--prefix", + args.name, + f"python={args.python}", + ], + "CREATE_CONDA.ENV_FAILED_CREATION", + ) + if args.git_ignore: + add_gitignore(args.name) + + env_path = get_conda_env_path(args.name) + print(f"CREATED_CONDA_ENV:{env_path}") + + if args.install: + install_packages(env_path) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/pythonFiles/create_venv.py b/pythonFiles/create_venv.py new file mode 100644 index 000000000000..4d9b551798e1 --- /dev/null +++ b/pythonFiles/create_venv.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import importlib.util as import_util +import os +import pathlib +import subprocess +import sys +from typing import Optional, Sequence, Union + +VENV_NAME = ".venv" +CWD = pathlib.PurePath(os.getcwd()) + + +class VenvError(Exception): + pass + + +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.", + ) + parser.add_argument( + "--git-ignore", + action="store_true", + default=False, + help="Add .gitignore to the newly created virtual environment.", + ) + parser.add_argument( + "--name", + default=VENV_NAME, + type=str, + help="Name of the virtual environment.", + metavar="NAME", + action="store", + ) + return parser.parse_args(argv) + + +def is_installed(module: str) -> bool: + return import_util.find_spec(module) is not None + + +def file_exists(path: Union[str, pathlib.PurePath]) -> bool: + return os.path.exists(path) + + +def venv_exists(name: str) -> bool: + return os.path.exists(CWD / name) + + +def run_process(args: Sequence[str], error_message: str) -> None: + try: + print("Running: " + " ".join(args)) + subprocess.run(args, cwd=os.getcwd(), check=True) + except subprocess.CalledProcessError: + raise VenvError(error_message) + + +def get_venv_path(name: str) -> str: + # See `venv` doc here for more details on binary location: + # https://docs.python.org/3/library/venv.html#creating-virtual-environments + if sys.platform == "win32": + return os.fspath(CWD / name / "Scripts" / "python.exe") + else: + return os.fspath(CWD / name / "bin" / "python") + + +def install_packages(venv_path: str) -> None: + if not is_installed("pip"): + raise VenvError("CREATE_VENV.PIP_NOT_FOUND") + + requirements = os.fspath(CWD / "requirements.txt") + pyproject = os.fspath(CWD / "pyproject.toml") + + 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", + ) + 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", + ) + + +def add_gitignore(name: str) -> None: + git_ignore = CWD / name / ".gitignore" + if not file_exists(git_ignore): + print("Creating: " + os.fspath(git_ignore)) + with open(git_ignore, "w") as f: + f.write("*") + + +def main(argv: Optional[Sequence[str]] = None) -> None: + if argv is None: + argv = [] + args = parse_args(argv) + + if is_installed("venv"): + if not venv_exists(args.name): + run_process( + [sys.executable, "-m", "venv", args.name], + "CREATE_VENV.VENV_FAILED_CREATION", + ) + if args.git_ignore: + add_gitignore(args.name) + venv_path = get_venv_path(args.name) + print(f"CREATED_VENV:{venv_path}") + if args.install: + install_packages(venv_path) + else: + raise VenvError("CREATE_VENV.VENV_NOT_FOUND") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/pythonFiles/tests/test_create_conda.py b/pythonFiles/tests/test_create_conda.py new file mode 100644 index 000000000000..29dc323402eb --- /dev/null +++ b/pythonFiles/tests/test_create_conda.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +import sys + +import create_conda +import pytest + + +@pytest.mark.parametrize("env_exists", [True, False]) +@pytest.mark.parametrize("git_ignore", [True, False]) +@pytest.mark.parametrize("install", [True, False]) +@pytest.mark.parametrize("python", [True, False]) +def test_create_env(env_exists, git_ignore, install, python): + importlib.reload(create_conda) + create_conda.conda_env_exists = lambda _n: env_exists + + install_packages_called = False + + def install_packages(_name): + nonlocal install_packages_called + install_packages_called = True + + create_conda.install_packages = install_packages + + run_process_called = False + + def run_process(args, error_message): + nonlocal run_process_called + run_process_called = True + version = ( + "12345" if python else f"{sys.version_info.major}.{sys.version_info.minor}" + ) + if not env_exists: + assert args == [ + sys.executable, + "-m", + "conda", + "create", + "--yes", + "--prefix", + create_conda.CONDA_ENV_NAME, + f"python={version}", + ] + assert error_message == "CREATE_CONDA.ENV_FAILED_CREATION" + + create_conda.run_process = run_process + + add_gitignore_called = False + + def add_gitignore(_name): + nonlocal add_gitignore_called + add_gitignore_called = True + + create_conda.add_gitignore = add_gitignore + + args = [] + if git_ignore: + args.append("--git-ignore") + if install: + args.append("--install") + if python: + args.extend(["--python", "12345"]) + create_conda.main(args) + assert install_packages_called == install + + # run_process is called when the venv does not exist + assert run_process_called != env_exists + + # add_gitignore is called when new venv is created and git_ignore is True + assert add_gitignore_called == (not env_exists and git_ignore) diff --git a/pythonFiles/tests/test_create_venv.py b/pythonFiles/tests/test_create_venv.py new file mode 100644 index 000000000000..e002ad17ef95 --- /dev/null +++ b/pythonFiles/tests/test_create_venv.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +import sys + +import create_venv +import pytest + + +def test_venv_not_installed(): + importlib.reload(create_venv) + create_venv.is_installed = lambda module: module != "venv" + with pytest.raises(create_venv.VenvError) as e: + create_venv.main() + assert str(e.value) == "CREATE_VENV.VENV_NOT_FOUND" + + +def test_pip_not_installed(): + 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"]) + 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]) +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 + + install_packages_called = False + + def install_packages(_name): + nonlocal install_packages_called + install_packages_called = True + + create_venv.install_packages = install_packages + + run_process_called = False + + def run_process(args, error_message): + nonlocal run_process_called + run_process_called = True + if not env_exists: + assert args == [sys.executable, "-m", "venv", create_venv.VENV_NAME] + assert error_message == "CREATE_VENV.VENV_FAILED_CREATION" + + create_venv.run_process = run_process + + add_gitignore_called = False + + def add_gitignore(_name): + nonlocal add_gitignore_called + add_gitignore_called = True + + create_venv.add_gitignore = add_gitignore + + args = [] + if git_ignore: + args.append("--git-ignore") + if install: + args.append("--install") + create_venv.main(args) + assert install_packages_called == install + + # run_process is called when the venv does not exist + assert run_process_called != env_exists + + # add_gitignore is called when new venv is created and git_ignore is True + assert add_gitignore_called == (not env_exists and git_ignore) + + +@pytest.mark.parametrize("install_type", ["requirements", "pyproject"]) +def test_install_packages(install_type): + importlib.reload(create_venv) + create_venv.is_installed = lambda _x: True + create_venv.file_exists = lambda x: install_type in x + + pip_upgraded = False + installing = None + + def run_process(args, error_message): + nonlocal pip_upgraded, installing + if args[1:] == ["-m", "pip", "install", "--upgrade", "pip"]: + pip_upgraded = True + assert error_message == "CREATE_VENV.PIP_UPGRADE_FAILED" + 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]"]: + installing = "pyproject" + assert error_message == "CREATE_VENV.PIP_FAILED_INSTALL_PYPROJECT" + + create_venv.run_process = run_process + + create_venv.main(["--install"]) + assert pip_upgraded + assert installing == install_type diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index a5570b28e5da..3dea7e4a6185 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -33,33 +33,34 @@ export enum CommandSource { } export namespace Commands { - export const Set_Interpreter = 'python.setInterpreter'; - export const Set_ShebangInterpreter = 'python.setShebangInterpreter'; - export const Exec_In_Terminal = 'python.execInTerminal'; - export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon'; - export const Debug_In_Terminal = 'python.debugInTerminal'; - export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal'; - export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; - export const Tests_Configure = 'python.configureTests'; - export const Sort_Imports = 'python.sortImports'; - export const ViewOutput = 'python.viewOutput'; - export const Start_REPL = 'python.startREPL'; + export const ClearStorage = 'python.clearCacheAndReload'; + export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter'; + export const Create_Environment = 'python.createEnvironment'; export const Create_Terminal = 'python.createTerminal'; - export const Set_Linter = 'python.setLinter'; + export const Debug_In_Terminal = 'python.debugInTerminal'; export const Enable_Linter = 'python.enableLinting'; - export const Run_Linter = 'python.runLinting'; export const Enable_SourceMap_Support = 'python.enableSourceMapSupport'; - export const PickLocalProcess = 'python.pickLocalProcess'; + export const Exec_In_Terminal = 'python.execInTerminal'; + export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon'; + export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; + export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal'; export const GetSelectedInterpreterPath = 'python.interpreterPath'; - export const ClearStorage = 'python.clearCacheAndReload'; - export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter'; + export const InstallPython = 'python.installPython'; + export const InstallPythonOnLinux = 'python.installPythonOnLinux'; + export const InstallPythonOnMac = 'python.installPythonOnMac'; export const LaunchTensorBoard = 'python.launchTensorBoard'; + export const PickLocalProcess = 'python.pickLocalProcess'; export const RefreshTensorBoard = 'python.refreshTensorBoard'; export const ReportIssue = 'python.reportIssue'; - export const InstallPython = 'python.installPython'; - export const InstallPythonOnMac = 'python.installPythonOnMac'; - export const InstallPythonOnLinux = 'python.installPythonOnLinux'; + export const Run_Linter = 'python.runLinting'; + export const Set_Interpreter = 'python.setInterpreter'; + export const Set_Linter = 'python.setLinter'; + export const Set_ShebangInterpreter = 'python.setShebangInterpreter'; + export const Sort_Imports = 'python.sortImports'; + export const Start_REPL = 'python.startREPL'; + export const Tests_Configure = 'python.configureTests'; export const TriggerEnvironmentSelection = 'python.triggerEnvSelection'; + export const ViewOutput = 'python.viewOutput'; } // Look at https://microsoft.github.io/vscode-codicons/dist/codicon.html for other Octicon icon ids diff --git a/src/client/common/process/internal/scripts/index.ts b/src/client/common/process/internal/scripts/index.ts index e0749cc18c67..d3f28097a5dc 100644 --- a/src/client/common/process/internal/scripts/index.ts +++ b/src/client/common/process/internal/scripts/index.ts @@ -158,3 +158,13 @@ export function linterScript(): string { const script = path.join(SCRIPTS_DIR, 'linter.py'); return script; } + +export function createVenvScript(): string { + const script = path.join(SCRIPTS_DIR, 'create_venv.py'); + return script; +} + +export function createCondaScript(): string { + const script = path.join(SCRIPTS_DIR, 'create_conda.py'); + return script; +} diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 2928f640d1be..412fe412f3d8 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -549,3 +549,74 @@ export namespace SwitchToDefaultLS { "The Microsoft Python Language Server has reached end of life. Your language server has been set to the default for Python in VS Code, Pylance.\n\nIf you'd like to change your language server, you can learn about how to do so [here](https://devblogs.microsoft.com/python/python-in-visual-studio-code-may-2021-release/#configuring-your-language-server).\n\nRead Pylance's license [here](https://marketplace.visualstudio.com/items/ms-python.vscode-pylance/license).", ); } + +export namespace CreateEnv { + export const statusTitle = localize('createEnv.statusTitle', 'Creating environment'); + export const statusStarting = localize('createEnv.statusStarting', 'Starting...'); + export const statusError = localize('createEnv.statusError', 'Error.'); + export const statusDone = localize('createEnv.statusDone', 'Done.'); + + export const hasVirtualEnv = localize('createEnv.hasVirtualEnv', 'Workspace folder contains a virtual environment'); + + export const noWorkspace = localize( + 'createEnv.noWorkspace', + 'Please open a directory when creating an environment using venv.', + ); + + export const pickWorkspaceTitle = localize( + 'createEnv.workspaceQuickPick.title', + 'Select a workspace to create environment', + ); + + export const providersQuickPickTitle = localize('createEnv.providersQuickPick.title', 'Select an environment type'); + + export namespace Venv { + export const creating = localize('createEnv.venv.creating', 'Creating venv...'); + export const created = localize('createEnv.venv.created', 'Environment created...'); + export const installingPackages = localize('createEnv.venv.installingPackages', 'Installing packages...'); + export const waitingForPython = localize('createEnv.venv.waitingForPython', 'Waiting on Python selection...'); + export const waitingForWorkspace = localize( + 'createEnv.venv.waitingForWorkspace', + 'Waiting on workspace selection...', + ); + export const selectPythonQuickPickTitle = localize( + 'createEnv.venv.basePython.title', + 'Select a python to use for environment creation', + ); + export const providerDescription = localize( + 'createEnv.venv.description', + 'Creates a `.venv` virtual environment in the current workspace', + ); + } + + export namespace Conda { + export const condaMissing = localize( + 'createEnv.conda.missing', + 'Please install `conda` to create conda environments.', + ); + export const created = localize('createEnv.conda.created', 'Environment created...'); + export const installingPackages = localize('createEnv.conda.installingPackages', 'Installing packages...'); + export const errorCreatingEnvironment = localize( + 'createEnv.conda.errorCreatingEnvironment', + 'Error while creating conda environment.', + ); + export const waitingForWorkspace = localize( + 'createEnv.conda.waitingForWorkspace', + 'Waiting on workspace selection...', + ); + export const waitingForPython = localize( + 'createEnv.conda.waitingForPython', + 'Waiting on Python version selection...', + ); + export const selectPythonQuickPickTitle = localize( + 'createEnv.conda.pythonSelection.title', + 'Please select the version of Python to install in the environment', + ); + export const searching = localize('createEnv.conda.searching', 'Searching for conda (base)...'); + export const creating = localize('createEnv.venv.creating', 'Running conda create...'); + export const providerDescription = localize( + 'createEnv.conda.description', + 'Creates a `.conda` Conda environment in the current workspace', + ); + } +} diff --git a/src/client/common/vscodeApis/commandApis.ts b/src/client/common/vscodeApis/commandApis.ts new file mode 100644 index 000000000000..580760e106e1 --- /dev/null +++ b/src/client/common/vscodeApis/commandApis.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { commands, Disposable } from 'vscode'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +export function registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): Disposable { + return commands.registerCommand(command, callback, thisArg); +} + +export function executeCommand(command: string, ...rest: any[]): Thenable { + return commands.executeCommand(command, ...rest); +} diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts new file mode 100644 index 000000000000..7def82abc752 --- /dev/null +++ b/src/client/common/vscodeApis/windowApis.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + CancellationToken, + MessageItem, + MessageOptions, + Progress, + ProgressOptions, + QuickPickItem, + QuickPickOptions, + window, +} from 'vscode'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function showQuickPick( + items: readonly T[] | Thenable, + options?: QuickPickOptions, + token?: CancellationToken, +): Thenable { + return window.showQuickPick(items, options, token); +} + +export function showErrorMessage(message: string, ...items: T[]): Thenable; +export function showErrorMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; +export function showErrorMessage(message: string, ...items: T[]): Thenable; +export function showErrorMessage( + message: string, + options: MessageOptions, + ...items: T[] +): Thenable; + +export function showErrorMessage(message: string, ...items: any[]): Thenable { + return window.showErrorMessage(message, ...items); +} + +export function withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => Thenable, +): Thenable { + return window.withProgress(options, task); +} diff --git a/src/client/common/vscodeApis/workspaceApis.ts b/src/client/common/vscodeApis/workspaceApis.ts new file mode 100644 index 000000000000..9e9af9e6f699 --- /dev/null +++ b/src/client/common/vscodeApis/workspaceApis.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { workspace, WorkspaceFolder } from 'vscode'; + +export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { + return workspace.workspaceFolders; +} + +export function getWorkspaceFolderPaths(): string[] { + return workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? []; +} diff --git a/src/client/extension.ts b/src/client/extension.ts index 312e99a38683..6069583489ef 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -35,7 +35,7 @@ import { IApplicationShell, IWorkspaceService } from './common/application/types import { IDisposableRegistry, IExperimentService, IExtensionContext } from './common/types'; import { createDeferred } from './common/utils/async'; import { Common } from './common/utils/localize'; -import { activateComponents } from './extensionActivation'; +import { activateComponents, activateFeatures } from './extensionActivation'; import { initializeStandard, initializeComponents, initializeGlobals } from './extensionInit'; import { IServiceContainer } from './ioc/types'; import { sendErrorTelemetry, sendStartupTelemetry } from './startupTelemetry'; @@ -128,6 +128,8 @@ async function activateUnsafe( // Then we finish activating. const componentsActivated = await activateComponents(ext, components); + activateFeatures(ext, components); + const nonBlocking = componentsActivated.map((r) => r.fullyReady); const activationPromise = (async () => { await Promise.all(nonBlocking); diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 54346088d252..3d2e026d7da4 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -61,6 +61,8 @@ import { DebugSessionEventDispatcher } from './debugger/extension/hooks/eventHan import { IDebugSessionEventHandlers } from './debugger/extension/hooks/types'; import { WorkspaceService } from './common/application/workspace'; import { DynamicPythonDebugConfigurationService } from './debugger/extension/configuration/dynamicdebugConfigurationService'; +import { registerCreateEnvironmentFeatures } from './pythonEnvironments/creation/createEnvApi'; +import { IInterpreterQuickPick } from './interpreter/configuration/types'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -95,6 +97,13 @@ export async function activateComponents( return Promise.all([legacyActivationResult, ...promises]); } +export function activateFeatures(ext: ExtensionState, components: Components): void { + const interpreterQuickPick: IInterpreterQuickPick = ext.legacyIOC.serviceContainer.get( + IInterpreterQuickPick, + ); + registerCreateEnvironmentFeatures(ext.disposables, components.pythonEnvs, interpreterQuickPick); +} + /// ////////////////////////// // old activation code diff --git a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts index a8ad85b05540..44a69019601c 100644 --- a/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts +++ b/src/client/pythonEnvironments/base/locators/composite/resolverUtils.ts @@ -24,7 +24,7 @@ import { getInterpreterPathFromDir, getPythonVersionFromPath, } from '../../../common/commonUtils'; -import { arePathsSame, getFileInfo, getWorkspaceFolders, isParentPath } from '../../../common/externalDependencies'; +import { arePathsSame, getFileInfo, isParentPath } from '../../../common/externalDependencies'; import { AnacondaCompanyName, Conda, isCondaEnvironment } from '../../../common/environmentManagers/conda'; import { getPyenvVersionsDir, parsePyenvVersion } from '../../../common/environmentManagers/pyenv'; import { Architecture, getOSType, OSType } from '../../../../common/utils/platform'; @@ -34,6 +34,7 @@ import { BasicEnvInfo } from '../../locator'; import { parseVersionFromExecutable } from '../../info/executable'; import { traceError, traceWarn } from '../../../../logging'; import { isVirtualEnvironment } from '../../../common/environmentManagers/simplevirtualenvs'; +import { getWorkspaceFolderPaths } from '../../../../common/vscodeApis/workspaceApis'; function getResolvers(): Map Promise> { const resolvers = new Map Promise>(); @@ -91,7 +92,7 @@ async function getEnvType(env: PythonEnvInfo) { } function getSearchLocation(env: PythonEnvInfo): Uri | undefined { - const folders = getWorkspaceFolders(); + const folders = getWorkspaceFolderPaths(); const isRootedEnv = folders.some((f) => isParentPath(env.executable.filename, f) || isParentPath(env.location, f)); if (isRootedEnv) { // For environments inside roots, we need to set search location so they can be queried accordingly. diff --git a/src/client/pythonEnvironments/common/externalDependencies.ts b/src/client/pythonEnvironments/common/externalDependencies.ts index 6ee5f1e5f6a1..d1ec026e6b4c 100644 --- a/src/client/pythonEnvironments/common/externalDependencies.ts +++ b/src/client/pythonEnvironments/common/externalDependencies.ts @@ -84,10 +84,6 @@ export function arePathsSame(path1: string, path2: string): boolean { return normCasePath(path1) === normCasePath(path2); } -export function getWorkspaceFolders(): string[] { - return vscode.workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? []; -} - export async function resolveSymbolicLink(absPath: string, stats?: fsapi.Stats): Promise { stats = stats ?? (await fsapi.lstat(absPath)); if (stats.isSymbolicLink()) { diff --git a/src/client/pythonEnvironments/creation/common/commonUtils.ts b/src/client/pythonEnvironments/creation/common/commonUtils.ts new file mode 100644 index 000000000000..f8fdeebbcf20 --- /dev/null +++ b/src/client/pythonEnvironments/creation/common/commonUtils.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { Commands } from '../../../common/constants'; +import { Common } from '../../../common/utils/localize'; +import { executeCommand } from '../../../common/vscodeApis/commandApis'; +import { showErrorMessage } from '../../../common/vscodeApis/windowApis'; + +export async function showErrorMessageWithLogs(message: string): Promise { + const result = await showErrorMessage(message, Common.openOutputPanel); + if (result === Common.openOutputPanel) { + await executeCommand(Commands.ViewOutput); + } +} diff --git a/src/client/pythonEnvironments/creation/common/workspaceSelection.ts b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts new file mode 100644 index 000000000000..810d51856bb8 --- /dev/null +++ b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fsapi from 'fs-extra'; +import * as path from 'path'; +import { QuickPickItem, WorkspaceFolder } from 'vscode'; +import { showErrorMessage, showQuickPick } from '../../../common/vscodeApis/windowApis'; +import { getWorkspaceFolders } from '../../../common/vscodeApis/workspaceApis'; +import { CreateEnv } from '../../../common/utils/localize'; + +function hasVirtualEnv(workspace: WorkspaceFolder): Promise { + return Promise.race([ + fsapi.pathExists(path.join(workspace.uri.fsPath, '.venv')), + fsapi.pathExists(path.join(workspace.uri.fsPath, '.conda')), + ]); +} + +async function getWorkspacesForQuickPick(workspaces: readonly WorkspaceFolder[]): Promise { + const items: QuickPickItem[] = []; + for (const workspace of workspaces) { + items.push({ + label: workspace.name, + detail: workspace.uri.fsPath, + description: (await hasVirtualEnv(workspace)) ? CreateEnv.hasVirtualEnv : undefined, + }); + } + + return items; +} + +export interface PickWorkspaceFolderOptions { + allowMultiSelect?: boolean; +} + +export async function pickWorkspaceFolder( + options?: PickWorkspaceFolderOptions, +): Promise { + const workspaces = getWorkspaceFolders(); + + if (!workspaces || workspaces.length === 0) { + showErrorMessage(CreateEnv.noWorkspace); + return undefined; + } + + if (workspaces.length === 1) { + return workspaces[0]; + } + + // This is multi-root scenario. + const selected = await showQuickPick(getWorkspacesForQuickPick(workspaces), { + title: CreateEnv.pickWorkspaceTitle, + ignoreFocusOut: true, + canPickMany: options?.allowMultiSelect, + }); + + if (selected) { + if (options?.allowMultiSelect) { + const details = ((selected as unknown) as QuickPickItem[]) + .map((s: QuickPickItem) => s.detail) + .filter((s) => s !== undefined); + return workspaces.filter((w) => details.includes(w.uri.fsPath)); + } + return workspaces.filter((w) => w.uri.fsPath === selected.detail)[0]; + } + + return undefined; +} diff --git a/src/client/pythonEnvironments/creation/createEnvApi.ts b/src/client/pythonEnvironments/creation/createEnvApi.ts new file mode 100644 index 000000000000..2546a858ced9 --- /dev/null +++ b/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable } from 'vscode'; +import { Commands } from '../../common/constants'; +import { IDisposableRegistry } from '../../common/types'; +import { registerCommand } from '../../common/vscodeApis/commandApis'; +import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; +import { IDiscoveryAPI } from '../base/locator'; +import { handleCreateEnvironmentCommand } from './createEnvQuickPick'; +import { condaCreationProvider } from './provider/condaCreationProvider'; +import { VenvCreationProvider } from './provider/venvCreationProvider'; +import { CreateEnvironmentOptions, CreateEnvironmentProvider } from './types'; + +class CreateEnvironmentProviders { + private _createEnvProviders: CreateEnvironmentProvider[] = []; + + constructor() { + this._createEnvProviders = []; + } + + public add(provider: CreateEnvironmentProvider) { + this._createEnvProviders.push(provider); + } + + public remove(provider: CreateEnvironmentProvider) { + this._createEnvProviders = this._createEnvProviders.filter((p) => p !== provider); + } + + public getAll(): readonly CreateEnvironmentProvider[] { + return this._createEnvProviders; + } +} + +const _createEnvironmentProviders: CreateEnvironmentProviders = new CreateEnvironmentProviders(); + +export function registerCreateEnvironmentProvider(provider: CreateEnvironmentProvider): Disposable { + _createEnvironmentProviders.add(provider); + return new Disposable(() => { + _createEnvironmentProviders.remove(provider); + }); +} + +export function registerCreateEnvironmentFeatures( + disposables: IDisposableRegistry, + discoveryApi: IDiscoveryAPI, + interpreterQuickPick: IInterpreterQuickPick, +): void { + disposables.push( + registerCommand( + Commands.Create_Environment, + (options?: CreateEnvironmentOptions): Promise => { + const providers = _createEnvironmentProviders.getAll(); + return handleCreateEnvironmentCommand(providers, options); + }, + ), + ); + disposables.push(registerCreateEnvironmentProvider(new VenvCreationProvider(discoveryApi, interpreterQuickPick))); + disposables.push(registerCreateEnvironmentProvider(condaCreationProvider())); +} diff --git a/src/client/pythonEnvironments/creation/createEnvQuickPick.ts b/src/client/pythonEnvironments/creation/createEnvQuickPick.ts new file mode 100644 index 000000000000..de71aa84cd06 --- /dev/null +++ b/src/client/pythonEnvironments/creation/createEnvQuickPick.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { QuickPickItem } from 'vscode'; +import { CreateEnv } from '../../common/utils/localize'; +import { showQuickPick } from '../../common/vscodeApis/windowApis'; +import { traceError } from '../../logging'; +import { createEnvironment } from './createEnvironment'; +import { CreateEnvironmentOptions, CreateEnvironmentProvider } from './types'; + +interface CreateEnvironmentProviderQuickPickItem extends QuickPickItem { + id: string; +} + +async function showCreateEnvironmentQuickPick( + providers: readonly CreateEnvironmentProvider[], +): Promise { + const items: CreateEnvironmentProviderQuickPickItem[] = providers.map((p) => ({ + label: p.name, + description: p.description, + id: p.id, + })); + const selected = await showQuickPick(items, { + title: CreateEnv.providersQuickPickTitle, + matchOnDescription: true, + ignoreFocusOut: true, + }); + + if (selected) { + const selections = providers.filter((p) => p.id === selected.id); + if (selections.length > 0) { + return selections[0]; + } + } + return undefined; +} + +export async function handleCreateEnvironmentCommand( + providers: readonly CreateEnvironmentProvider[], + options?: CreateEnvironmentOptions, +): Promise { + if (providers.length === 1) { + return createEnvironment(providers[0], options); + } + if (providers.length > 1) { + const provider = await showCreateEnvironmentQuickPick(providers); + if (provider) { + return createEnvironment(provider, options); + } + } else { + traceError('No Environment Creation providers were registered.'); + } + return undefined; +} diff --git a/src/client/pythonEnvironments/creation/createEnvironment.ts b/src/client/pythonEnvironments/creation/createEnvironment.ts new file mode 100644 index 000000000000..dc6dc9461cfd --- /dev/null +++ b/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { CancellationToken, ProgressLocation } from 'vscode'; +import { withProgress } from '../../common/vscodeApis/windowApis'; +import { traceError } from '../../logging'; +import { CreateEnvironmentOptions, CreateEnvironmentProgress, CreateEnvironmentProvider } from './types'; +import { CreateEnv } from '../../common/utils/localize'; + +export async function createEnvironment( + provider: CreateEnvironmentProvider, + options: CreateEnvironmentOptions = { + ignoreSourceControl: true, + installPackages: true, + }, +): Promise { + return withProgress( + { + location: ProgressLocation.Notification, + title: CreateEnv.statusTitle, + cancellable: true, + }, + async (progress: CreateEnvironmentProgress, token: CancellationToken) => { + let hasError = false; + progress.report({ + message: CreateEnv.statusStarting, + }); + try { + const result = await provider.createEnvironment(options, progress, token); + return result; + } catch (ex) { + traceError(ex); + hasError = true; + progress.report({ + message: CreateEnv.statusError, + }); + throw ex; + } finally { + if (!hasError) { + progress.report({ + message: CreateEnv.statusDone, + }); + } + } + }, + ); +} diff --git a/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts new file mode 100644 index 000000000000..81ef43334449 --- /dev/null +++ b/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, WorkspaceFolder } from 'vscode'; +import * as path from 'path'; +import { PVSC_EXTENSION_ID } from '../../../common/constants'; +import { traceError, traceLog } from '../../../logging'; +import { CreateEnvironmentOptions, CreateEnvironmentProgress, CreateEnvironmentProvider } from '../types'; +import { pickWorkspaceFolder } from '../common/workspaceSelection'; +import { execObservable } from '../../../common/process/rawProcessApis'; +import { createDeferred } from '../../../common/utils/async'; +import { getEnvironmentVariable, getOSType, OSType } from '../../../common/utils/platform'; +import { createCondaScript } from '../../../common/process/internal/scripts'; +import { CreateEnv } from '../../../common/utils/localize'; +import { getConda, pickPythonVersion } from './condaUtils'; +import { showErrorMessageWithLogs } from '../common/commonUtils'; + +export const CONDA_ENV_CREATED_MARKER = 'CREATED_CONDA_ENV:'; +export const CONDA_INSTALLING_YML = 'CONDA_INSTALLING_YML:'; + +function generateCommandArgs(version?: string, 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; + } + + const command: string[] = [createCondaScript()]; + + if (addGitIgnore) { + command.push('--git-ignore'); + } + + if (installPackages) { + command.push('--install'); + } + + if (version) { + command.push('--python'); + command.push(version); + } + + return command; +} + +async function createCondaEnv( + workspace: WorkspaceFolder, + command: string, + args: string[], + progress?: CreateEnvironmentProgress, + token?: CancellationToken, +): Promise { + const deferred = createDeferred(); + let pathEnv = getEnvironmentVariable('PATH') || getEnvironmentVariable('Path') || ''; + if (getOSType() === OSType.Windows) { + // On windows `conda.bat` is used, which adds the following bin directories to PATH + // then launches `conda.exe` which is a stub to `python.exe -m conda`. Here, we are + // instead using the `python.exe` that ships with conda to run a python script that + // handles conda env creation and package installation. + // See conda issue: https://github.com/conda/conda/issues/11399 + const root = path.dirname(command); + const libPath1 = path.join(root, 'Library', 'bin'); + const libPath2 = path.join(root, 'Library', 'mingw-w64', 'bin'); + const libPath3 = path.join(root, 'Library', 'usr', 'bin'); + const libPath4 = path.join(root, 'bin'); + const libPath5 = path.join(root, 'Scripts'); + const libPath = [libPath1, libPath2, libPath3, libPath4, libPath5].join(path.delimiter); + pathEnv = `${libPath}${path.delimiter}${pathEnv}`; + } + traceLog('Running Conda Env creation script: ', [command, ...args]); + const { out, dispose } = execObservable(command, args, { + mergeStdOutErr: true, + token, + cwd: workspace.uri.fsPath, + env: { + PATH: pathEnv, + }, + }); + + let condaEnvPath: string | undefined; + out.subscribe( + (value) => { + const output = value.out.splitLines().join('\r\n'); + traceLog(output); + if (output.includes(CONDA_ENV_CREATED_MARKER)) { + progress?.report({ + message: CreateEnv.Conda.created, + }); + try { + const envPath = output + .split(/\r?\n/g) + .map((s) => s.trim()) + .filter((s) => s.startsWith(CONDA_ENV_CREATED_MARKER))[0]; + condaEnvPath = envPath.substring(CONDA_ENV_CREATED_MARKER.length); + } catch (ex) { + traceError('Parsing out environment path failed.'); + condaEnvPath = undefined; + } + } else if (output.includes(CONDA_INSTALLING_YML)) { + progress?.report({ + message: CreateEnv.Conda.installingPackages, + }); + } + }, + async (error) => { + traceError('Error while running conda env creation script: ', error); + deferred.reject(error); + await showErrorMessageWithLogs(CreateEnv.Conda.errorCreatingEnvironment); + }, + () => { + dispose(); + if (!deferred.rejected) { + deferred.resolve(condaEnvPath); + } + }, + ); + return deferred.promise; +} + +function getExecutableCommand(condaPath: string): string { + if (getOSType() === OSType.Windows) { + // Both Miniconda3 and Anaconda3 have the following structure: + // Miniconda3 (or Anaconda3) + // |- condabin + // | |- conda.bat <--- this actually points to python.exe below, + // | after adding few paths to PATH. + // |- Scripts + // | |- conda.exe <--- this is the path we get as condaPath, + // | which is really a stub for `python.exe -m conda`. + // |- python.exe <--- this is the python that we want. + return path.join(path.dirname(path.dirname(condaPath)), 'python.exe'); + } + // On non-windows machines: + // miniconda (or miniforge or anaconda3) + // |- bin + // |- conda <--- this is the path we get as condaPath. + // |- python <--- this is the python that we want. + return path.join(path.dirname(condaPath), 'python'); +} + +async function createEnvironment( + options?: CreateEnvironmentOptions, + progress?: CreateEnvironmentProgress, + token?: CancellationToken, +): Promise { + progress?.report({ + message: CreateEnv.Conda.searching, + }); + const conda = await getConda(); + if (!conda) { + return undefined; + } + + progress?.report({ + message: CreateEnv.Conda.waitingForWorkspace, + }); + const workspace = (await pickWorkspaceFolder()) as WorkspaceFolder | undefined; + if (!workspace) { + traceError('Workspace was not selected or found for creating virtual env.'); + return undefined; + } + + progress?.report({ + message: CreateEnv.Conda.waitingForPython, + }); + const version = await pickPythonVersion(); + if (!version) { + traceError('Conda environments for use with python extension require Python.'); + return undefined; + } + + progress?.report({ + message: CreateEnv.Conda.creating, + }); + const args = generateCommandArgs(version, options); + return createCondaEnv(workspace, getExecutableCommand(conda), args, progress, token); +} + +export function condaCreationProvider(): CreateEnvironmentProvider { + return { + createEnvironment, + name: 'Conda', + + description: CreateEnv.Conda.providerDescription, + + id: `${PVSC_EXTENSION_ID}:conda`, + }; +} diff --git a/src/client/pythonEnvironments/creation/provider/condaUtils.ts b/src/client/pythonEnvironments/creation/provider/condaUtils.ts new file mode 100644 index 000000000000..fd35c82267ac --- /dev/null +++ b/src/client/pythonEnvironments/creation/provider/condaUtils.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { QuickPickItem, Uri } from 'vscode'; +import { Common } from '../../../browser/localize'; +import { CreateEnv } from '../../../common/utils/localize'; +import { executeCommand } from '../../../common/vscodeApis/commandApis'; +import { showErrorMessage, showQuickPick } from '../../../common/vscodeApis/windowApis'; +import { Conda } from '../../common/environmentManagers/conda'; + +export async function getConda(): Promise { + const conda = await Conda.getConda(); + + if (!conda) { + const response = await showErrorMessage(CreateEnv.Conda.condaMissing, Common.learnMore); + if (response === Common.learnMore) { + await executeCommand('vscode.open', Uri.parse('https://docs.anaconda.com/anaconda/install/')); + } + return undefined; + } + return conda.command; +} + +export async function pickPythonVersion(): Promise { + const items: QuickPickItem[] = ['3.7', '3.8', '3.9', '3.10'].map((v) => ({ + label: `Python`, + description: v, + })); + const version = await showQuickPick(items, { + title: CreateEnv.Conda.selectPythonQuickPickTitle, + }); + return version?.description; +} diff --git a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts new file mode 100644 index 000000000000..f6ad31374eb3 --- /dev/null +++ b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, WorkspaceFolder } from 'vscode'; +import { PVSC_EXTENSION_ID } from '../../../common/constants'; +import { createVenvScript } from '../../../common/process/internal/scripts'; +import { execObservable } from '../../../common/process/rawProcessApis'; +import { createDeferred } from '../../../common/utils/async'; +import { CreateEnv } from '../../../common/utils/localize'; +import { traceError, traceLog } from '../../../logging'; +import { PythonEnvKind } from '../../base/info'; +import { IDiscoveryAPI } from '../../base/locator'; +import { CreateEnvironmentOptions, CreateEnvironmentProgress, CreateEnvironmentProvider } from '../types'; +import { pickWorkspaceFolder } from '../common/workspaceSelection'; +import { IInterpreterQuickPick } from '../../../interpreter/configuration/types'; +import { EnvironmentType, PythonEnvironment } from '../../info'; + +export const VENV_CREATED_MARKER = 'CREATED_VENV:'; +export const INSTALLING_REQUIREMENTS = 'VENV_INSTALLING_REQUIREMENTS:'; +export const INSTALLING_PYPROJECT = 'VENV_INSTALLING_PYPROJECT:'; +export const PIP_NOT_INSTALLED_MARKER = 'CREATE_VENV.PIP_NOT_FOUND'; +export const VENV_NOT_INSTALLED_MARKER = 'CREATE_VENV.VENV_NOT_FOUND'; +export const INSTALL_REQUIREMENTS_FAILED_MARKER = 'CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS'; +export const INSTALL_PYPROJECT_FAILED_MARKER = 'CREATE_VENV.PIP_FAILED_INSTALL_PYPROJECT'; + +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; + } + + const command: string[] = [createVenvScript()]; + + if (addGitIgnore) { + command.push('--git-ignore'); + } + + if (installPackages) { + command.push('--install'); + } + + return command; +} + +async function createVenv( + workspace: WorkspaceFolder, + command: string, + args: string[], + progress?: CreateEnvironmentProgress, + token?: CancellationToken, +): Promise { + progress?.report({ + message: CreateEnv.Venv.creating, + }); + const deferred = createDeferred(); + traceLog('Running Env creation script: ', [command, ...args]); + const { out, dispose } = execObservable(command, args, { + mergeStdOutErr: true, + token, + cwd: workspace.uri.fsPath, + }); + + let venvPath: string | undefined; + out.subscribe( + (value) => { + const output = value.out.split(/\r?\n/g).join('\r\n'); + traceLog(output); + if (output.includes(VENV_CREATED_MARKER)) { + progress?.report({ + message: CreateEnv.Venv.created, + }); + try { + const envPath = output + .split(/\r?\n/g) + .map((s) => s.trim()) + .filter((s) => s.startsWith(VENV_CREATED_MARKER))[0]; + venvPath = envPath.substring(VENV_CREATED_MARKER.length); + } catch (ex) { + traceError('Parsing out environment path failed.'); + venvPath = undefined; + } + } else if (output.includes(INSTALLING_REQUIREMENTS) || output.includes(INSTALLING_PYPROJECT)) { + progress?.report({ + message: CreateEnv.Venv.installingPackages, + }); + } + }, + (error) => { + traceError('Error while running venv creation script: ', error); + deferred.reject(error); + }, + () => { + dispose(); + if (!deferred.rejected) { + deferred.resolve(venvPath); + } + }, + ); + return deferred.promise; +} + +export class VenvCreationProvider implements CreateEnvironmentProvider { + constructor( + private readonly discoveryApi: IDiscoveryAPI, + private readonly interpreterQuickPick: IInterpreterQuickPick, + ) {} + + public async createEnvironment( + options?: CreateEnvironmentOptions, + progress?: CreateEnvironmentProgress, + token?: CancellationToken, + ): Promise { + progress?.report({ + message: CreateEnv.Venv.waitingForWorkspace, + }); + + const workspace = (await pickWorkspaceFolder()) as WorkspaceFolder | undefined; + if (workspace === undefined) { + traceError('Workspace was not selected or found for creating virtual environment.'); + return undefined; + } + + progress?.report({ + message: CreateEnv.Venv.waitingForPython, + }); + const interpreters = this.discoveryApi.getEnvs({ + kinds: [PythonEnvKind.MicrosoftStore, PythonEnvKind.OtherGlobal], + }); + + const args = generateCommandArgs(options); + if (interpreters.length === 1) { + return createVenv(workspace, interpreters[0].executable.filename, args, progress, token); + } + + const interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( + workspace.uri, + (i: PythonEnvironment) => + [EnvironmentType.System, EnvironmentType.MicrosoftStore, EnvironmentType.Global].includes(i.envType), + ); + + if (interpreter) { + return createVenv(workspace, interpreter, args, progress, token); + } + + traceError('Virtual env creation requires an interpreter.'); + return undefined; + } + + name = 'Venv'; + + description: string = CreateEnv.Venv.providerDescription; + + id = `${PVSC_EXTENSION_ID}:venv`; +} diff --git a/src/client/pythonEnvironments/creation/types.ts b/src/client/pythonEnvironments/creation/types.ts new file mode 100644 index 000000000000..9e9a31799d09 --- /dev/null +++ b/src/client/pythonEnvironments/creation/types.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { CancellationToken, Progress } from 'vscode'; + +export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {} + +export interface CreateEnvironmentOptions { + installPackages?: boolean; + ignoreSourceControl?: boolean; +} + +export interface CreateEnvironmentProvider { + createEnvironment( + options?: CreateEnvironmentOptions, + progress?: CreateEnvironmentProgress, + token?: CancellationToken, + ): Promise; + name: string; + description: string; + id: string; +} diff --git a/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts index f0c1a7d022f0..4a480cfd6e44 100644 --- a/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/envsResolver.unit.test.ts @@ -38,6 +38,7 @@ import { createBasicEnv, getEnvs, getEnvsWithUpdates, SimpleLocator } from '../. import { getOSType, OSType } from '../../../../common'; import { CondaInfo } from '../../../../../client/pythonEnvironments/common/environmentManagers/conda'; import { createDeferred } from '../../../../../client/common/utils/async'; +import * as workspaceApis from '../../../../../client/common/vscodeApis/workspaceApis'; suite('Python envs locator - Environments Resolver', () => { let envInfoService: IEnvironmentInfoService; @@ -115,7 +116,7 @@ suite('Python envs locator - Environments Resolver', () => { }); }), ); - sinon.stub(externalDependencies, 'getWorkspaceFolders').returns([testVirtualHomeDir]); + sinon.stub(workspaceApis, 'getWorkspaceFolderPaths').returns([testVirtualHomeDir]); }); teardown(() => { @@ -349,7 +350,7 @@ suite('Python envs locator - Environments Resolver', () => { }); }), ); - sinon.stub(externalDependencies, 'getWorkspaceFolders').returns([testVirtualHomeDir]); + sinon.stub(workspaceApis, 'getWorkspaceFolderPaths').returns([testVirtualHomeDir]); }); teardown(() => { diff --git a/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts index 322a4cb8cc91..2310f6dc942f 100644 --- a/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/resolverUtils.unit.test.ts @@ -26,12 +26,13 @@ import { CondaInfo, } from '../../../../../client/pythonEnvironments/common/environmentManagers/conda'; import { resolveBasicEnv } from '../../../../../client/pythonEnvironments/base/locators/composite/resolverUtils'; +import * as workspaceApis from '../../../../../client/common/vscodeApis/workspaceApis'; suite('Resolver Utils', () => { let getWorkspaceFolders: sinon.SinonStub; setup(() => { sinon.stub(externalDependencies, 'getPythonSetting').withArgs('condaPath').returns('conda'); - getWorkspaceFolders = sinon.stub(externalDependencies, 'getWorkspaceFolders'); + getWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolderPaths'); getWorkspaceFolders.returns([]); }); diff --git a/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts b/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts new file mode 100644 index 000000000000..03ec08ecd83b --- /dev/null +++ b/src/test/pythonEnvironments/creation/common/workspaceSelection.unit.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { assert } from 'chai'; +import * as sinon from 'sinon'; +// import * as typemoq from 'typemoq'; +import { Uri, WorkspaceFolder } from 'vscode'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; +import { pickWorkspaceFolder } from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; +import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; + +suite('Create environment workspace selection tests', () => { + let showQuickPickStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + + setup(() => { + showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No workspaces (undefined)', async () => { + getWorkspaceFoldersStub.returns(undefined); + assert.isUndefined(await pickWorkspaceFolder()); + assert.isTrue(showErrorMessageStub.calledOnce); + }); + + test('No workspaces (empty array)', async () => { + getWorkspaceFoldersStub.returns([]); + assert.isUndefined(await pickWorkspaceFolder()); + assert.isTrue(showErrorMessageStub.calledOnce); + }); + + test('User did not select workspace', async () => { + const workspaces: WorkspaceFolder[] = [ + { + uri: Uri.file('some_folder'), + name: 'some_folder', + index: 0, + }, + { + uri: Uri.file('some_folder2'), + name: 'some_folder2', + index: 1, + }, + ]; + + getWorkspaceFoldersStub.returns(workspaces); + showQuickPickStub.returns(undefined); + assert.isUndefined(await pickWorkspaceFolder()); + }); + + test('single workspace scenario', async () => { + const workspaces: WorkspaceFolder[] = [ + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }, + ]; + + getWorkspaceFoldersStub.returns(workspaces); + showQuickPickStub.returns({ + label: workspaces[0].name, + detail: workspaces[0].uri.fsPath, + description: undefined, + }); + + const workspace = await pickWorkspaceFolder(); + assert.deepEqual(workspace, workspaces[0]); + assert(showQuickPickStub.notCalled); + }); + + test('Multi-workspace scenario with single workspace selected', async () => { + const workspaces: WorkspaceFolder[] = [ + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace2')), + name: 'workspace2', + index: 1, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace3')), + name: 'workspace3', + index: 2, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace4')), + name: 'workspace4', + index: 3, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace5')), + name: 'workspace5', + index: 4, + }, + ]; + + getWorkspaceFoldersStub.returns(workspaces); + showQuickPickStub.returns({ + label: workspaces[1].name, + detail: workspaces[1].uri.fsPath, + description: undefined, + }); + + const workspace = await pickWorkspaceFolder(); + assert.deepEqual(workspace, workspaces[1]); + assert(showQuickPickStub.calledOnce); + }); + + test('Multi-workspace scenario with multiple workspaces selected', async () => { + const workspaces: WorkspaceFolder[] = [ + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace2')), + name: 'workspace2', + index: 1, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace3')), + name: 'workspace3', + index: 2, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace4')), + name: 'workspace4', + index: 3, + }, + { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace5')), + name: 'workspace5', + index: 4, + }, + ]; + + getWorkspaceFoldersStub.returns(workspaces); + showQuickPickStub.returns([ + { + label: workspaces[1].name, + detail: workspaces[1].uri.fsPath, + description: undefined, + }, + { + label: workspaces[3].name, + detail: workspaces[3].uri.fsPath, + description: undefined, + }, + ]); + + const workspace = await pickWorkspaceFolder({ allowMultiSelect: true }); + assert.deepEqual(workspace, [workspaces[1], workspaces[3]]); + assert(showQuickPickStub.calledOnce); + }); +}); diff --git a/src/test/pythonEnvironments/creation/createEnvQuickPick.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvQuickPick.unit.test.ts new file mode 100644 index 000000000000..165dff8c6b2b --- /dev/null +++ b/src/test/pythonEnvironments/creation/createEnvQuickPick.unit.test.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import * as createEnv from '../../../client/pythonEnvironments/creation/createEnvironment'; +import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvQuickPick'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; + +suite('Create Environment Command Handler Tests', () => { + let showQuickPickStub: sinon.SinonStub; + let createEnvironmentStub: sinon.SinonStub; + + setup(() => { + showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + createEnvironmentStub = sinon.stub(createEnv, 'createEnvironment'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No providers registered', async () => { + await handleCreateEnvironmentCommand([]); + + assert.isTrue(showQuickPickStub.notCalled); + assert.isTrue(createEnvironmentStub.notCalled); + }); + + test('Single environment creation provider registered', async () => { + const provider = typemoq.Mock.ofType(); + provider.setup((p) => p.name).returns(() => 'test'); + provider.setup((p) => p.id).returns(() => 'test-id'); + provider.setup((p) => p.description).returns(() => 'test-description'); + + await handleCreateEnvironmentCommand([provider.object]); + + assert.isTrue(showQuickPickStub.notCalled); + createEnvironmentStub.calledOnceWithExactly(provider.object, undefined); + }); + + test('Multiple environment creation providers registered', async () => { + const provider1 = typemoq.Mock.ofType(); + provider1.setup((p) => p.name).returns(() => 'test1'); + provider1.setup((p) => p.id).returns(() => 'test-id1'); + provider1.setup((p) => p.description).returns(() => 'test-description1'); + + const provider2 = typemoq.Mock.ofType(); + provider2.setup((p) => p.name).returns(() => 'test2'); + provider2.setup((p) => p.id).returns(() => 'test-id2'); + provider2.setup((p) => p.description).returns(() => 'test-description2'); + + showQuickPickStub.resolves({ + id: 'test-id2', + label: 'test2', + description: 'test-description2', + }); + + provider1.setup((p) => (p as any).then).returns(() => undefined); + provider2.setup((p) => (p as any).then).returns(() => undefined); + await handleCreateEnvironmentCommand([provider1.object, provider2.object]); + + assert.isTrue(showQuickPickStub.calledOnce); + createEnvironmentStub.calledOnceWithExactly(provider2.object, undefined); + }); +}); diff --git a/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts new file mode 100644 index 000000000000..f9c12d1f6eb0 --- /dev/null +++ b/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import { ProgressLocation, ProgressOptions } from 'vscode'; +import { CreateEnv } from '../../../client/common/utils/localize'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import { createEnvironment } from '../../../client/pythonEnvironments/creation/createEnvironment'; +import { + CreateEnvironmentProgress, + CreateEnvironmentProvider, +} from '../../../client/pythonEnvironments/creation/types'; + +chaiUse(chaiAsPromised); + +suite('Create Environments Tests', () => { + let withProgressStub: sinon.SinonStub; + let progressMock: typemoq.IMock; + + setup(() => { + progressMock = typemoq.Mock.ofType(); + withProgressStub = sinon.stub(windowApis, 'withProgress'); + withProgressStub.callsFake(async (options: ProgressOptions, task) => { + assert.deepEqual(options, { + location: ProgressLocation.Notification, + title: CreateEnv.statusTitle, + cancellable: true, + }); + + await task(progressMock.object, undefined); + }); + }); + + teardown(() => { + progressMock.reset(); + sinon.restore(); + }); + + test('Successful environment creation', async () => { + const provider = typemoq.Mock.ofType(); + provider + .setup((p) => p.createEnvironment(typemoq.It.isAny(), progressMock.object, undefined)) + .returns(() => Promise.resolve(undefined)); + progressMock.setup((p) => p.report({ message: CreateEnv.statusStarting })).verifiable(typemoq.Times.once()); + progressMock.setup((p) => p.report({ message: CreateEnv.statusDone })).verifiable(typemoq.Times.once()); + progressMock.setup((p) => p.report({ message: CreateEnv.statusError })).verifiable(typemoq.Times.never()); + await createEnvironment(provider.object); + + progressMock.verifyAll(); + provider.verifyAll(); + }); + + test('Environment creation error', async () => { + const provider = typemoq.Mock.ofType(); + provider + .setup((p) => p.createEnvironment(typemoq.It.isAny(), progressMock.object, undefined)) + .returns(() => Promise.reject()); + progressMock.setup((p) => p.report({ message: CreateEnv.statusStarting })).verifiable(typemoq.Times.once()); + progressMock.setup((p) => p.report({ message: CreateEnv.statusDone })).verifiable(typemoq.Times.never()); + progressMock.setup((p) => p.report({ message: CreateEnv.statusError })).verifiable(typemoq.Times.once()); + + await assert.isRejected(createEnvironment(provider.object)); + + progressMock.verifyAll(); + provider.verifyAll(); + }); +}); diff --git a/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts new file mode 100644 index 000000000000..fffcd2511a15 --- /dev/null +++ b/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as chaiAsPromised from 'chai-as-promised'; +import * as path from 'path'; +import { assert, use as chaiUse } from 'chai'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { CreateEnvironmentProvider } from '../../../../client/pythonEnvironments/creation/types'; +import { + condaCreationProvider, + CONDA_ENV_CREATED_MARKER, +} from '../../../../client/pythonEnvironments/creation/provider/condaCreationProvider'; +import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; +import * as condaUtils from '../../../../client/pythonEnvironments/creation/provider/condaUtils'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; +import * as rawProcessApis from '../../../../client/common/process/rawProcessApis'; +import { Output } from '../../../../client/common/process/types'; +import { createDeferred } from '../../../../client/common/utils/async'; +import * as commonUtils from '../../../../client/pythonEnvironments/creation/common/commonUtils'; + +chaiUse(chaiAsPromised); + +suite('Conda Creation provider tests', () => { + let condaProvider: CreateEnvironmentProvider; + let getCondaStub: sinon.SinonStub; + let pickPythonVersionStub: sinon.SinonStub; + let pickWorkspaceFolderStub: sinon.SinonStub; + let execObservableStub: sinon.SinonStub; + + let showErrorMessageWithLogsStub: sinon.SinonStub; + + setup(() => { + pickWorkspaceFolderStub = sinon.stub(wsSelect, 'pickWorkspaceFolder'); + getCondaStub = sinon.stub(condaUtils, 'getConda'); + pickPythonVersionStub = sinon.stub(condaUtils, 'pickPythonVersion'); + execObservableStub = sinon.stub(rawProcessApis, 'execObservable'); + showErrorMessageWithLogsStub = sinon.stub(commonUtils, 'showErrorMessageWithLogs'); + showErrorMessageWithLogsStub.resolves(); + + condaProvider = condaCreationProvider(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No conda installed', async () => { + getCondaStub.resolves(undefined); + + assert.isUndefined(await condaProvider.createEnvironment()); + }); + + test('No workspace selected', async () => { + getCondaStub.resolves('/usr/bin/conda'); + pickWorkspaceFolderStub.resolves(undefined); + + assert.isUndefined(await condaProvider.createEnvironment()); + }); + + test('No python version picked selected', async () => { + getCondaStub.resolves('/usr/bin/conda'); + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + pickPythonVersionStub.resolves(undefined); + + assert.isUndefined(await condaProvider.createEnvironment()); + }); + + test('Create conda environment', async () => { + getCondaStub.resolves('/usr/bin/conda/conda_bin/conda'); + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + pickPythonVersionStub.resolves('3.10'); + + const deferred = createDeferred(); + let _next: undefined | ((value: Output) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: undefined, + out: { + subscribe: ( + next?: (value: Output) => void, + _error?: (error: unknown) => void, + complete?: () => void, + ) => { + _next = next; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + const promise = condaProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_next); + assert.isDefined(_complete); + + _next!({ out: `${CONDA_ENV_CREATED_MARKER}new_environment`, source: 'stdout' }); + _complete!(); + assert.strictEqual(await promise, 'new_environment'); + }); + + test('Create conda environment failed', async () => { + getCondaStub.resolves('/usr/bin/conda/conda_bin/conda'); + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + pickPythonVersionStub.resolves('3.10'); + + const deferred = createDeferred(); + let _error: undefined | ((error: unknown) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: undefined, + out: { + subscribe: ( + _next?: (value: Output) => void, + error?: (error: unknown) => void, + complete?: () => void, + ) => { + _error = error; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + const promise = condaProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_error); + _error!('bad arguments'); + _complete!(); + await assert.isRejected(promise); + }); +}); diff --git a/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts new file mode 100644 index 000000000000..e30c86e78a4f --- /dev/null +++ b/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as chaiAsPromised from 'chai-as-promised'; +import * as path from 'path'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { CreateEnvironmentProvider } from '../../../../client/pythonEnvironments/creation/types'; +import { + VenvCreationProvider, + VENV_CREATED_MARKER, +} from '../../../../client/pythonEnvironments/creation/provider/venvCreationProvider'; +import { IDiscoveryAPI } from '../../../../client/pythonEnvironments/base/locator'; +import { IInterpreterQuickPick } from '../../../../client/interpreter/configuration/types'; +import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; +import * as rawProcessApis from '../../../../client/common/process/rawProcessApis'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; +import { PythonEnvKind, PythonEnvSource } from '../../../../client/pythonEnvironments/base/info'; +import { Architecture } from '../../../../client/common/utils/platform'; +import { createDeferred } from '../../../../client/common/utils/async'; +import { Output } from '../../../../client/common/process/types'; + +chaiUse(chaiAsPromised); + +const python37 = { + name: 'Python 3.7', + kind: PythonEnvKind.System, + location: '/usr/bin/python3.7', + source: [PythonEnvSource.PathEnvVar], + executable: { + filename: '/usr/bin/python3.7', + ctime: 0, + mtime: 0, + sysPrefix: '', + }, + version: { + major: 3, + minor: 7, + micro: 7, + }, + arch: Architecture.x64, + distro: { + org: 'python', + }, +}; +const python38 = { + name: 'Python 3.8', + kind: PythonEnvKind.System, + location: '/usr/bin/python3.8', + source: [PythonEnvSource.PathEnvVar], + executable: { + filename: '/usr/bin/python3.8', + ctime: 0, + mtime: 0, + sysPrefix: '', + }, + version: { + major: 3, + minor: 8, + micro: 8, + }, + arch: Architecture.x64, + distro: { + org: 'python', + }, +}; + +suite('venv Creation provider tests', () => { + let venvProvider: CreateEnvironmentProvider; + let pickWorkspaceFolderStub: sinon.SinonStub; + let discoveryApi: typemoq.IMock; + let interpreterQuickPick: typemoq.IMock; + let execObservableStub: sinon.SinonStub; + + setup(() => { + pickWorkspaceFolderStub = sinon.stub(wsSelect, 'pickWorkspaceFolder'); + execObservableStub = sinon.stub(rawProcessApis, 'execObservable'); + discoveryApi = typemoq.Mock.ofType(); + interpreterQuickPick = typemoq.Mock.ofType(); + venvProvider = new VenvCreationProvider(discoveryApi.object, interpreterQuickPick.object); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No workspace selected', async () => { + pickWorkspaceFolderStub.resolves(undefined); + + assert.isUndefined(await venvProvider.createEnvironment()); + assert.isTrue(pickWorkspaceFolderStub.calledOnce); + }); + + test('No Python selected', async () => { + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + + // Return multiple envs here to force user selection. + discoveryApi + .setup((d) => d.getEnvs(typemoq.It.isAny())) + .returns(() => [python37, python38]) + .verifiable(typemoq.Times.once()); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve(undefined)) + .verifiable(typemoq.Times.once()); + + assert.isUndefined(await venvProvider.createEnvironment()); + discoveryApi.verifyAll(); + interpreterQuickPick.verifyAll(); + }); + + test('Create venv with single global python', async () => { + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + + // Return single env here to skip user selection. + discoveryApi + .setup((d) => d.getEnvs(typemoq.It.isAny())) + .returns(() => [python38]) + .verifiable(typemoq.Times.once()); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve(undefined)) + .verifiable(typemoq.Times.never()); + + const deferred = createDeferred(); + let _next: undefined | ((value: Output) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: undefined, + out: { + subscribe: ( + next?: (value: Output) => void, + _error?: (error: unknown) => void, + complete?: () => void, + ) => { + _next = next; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + const promise = venvProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_next); + assert.isDefined(_complete); + + _next!({ out: `${VENV_CREATED_MARKER}new_environment`, source: 'stdout' }); + _complete!(); + assert.strictEqual(await promise, 'new_environment'); + discoveryApi.verifyAll(); + interpreterQuickPick.verifyAll(); + }); + + test('Create venv with multiple global python', async () => { + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + + // Return single env here to skip user selection. + discoveryApi + .setup((d) => d.getEnvs(typemoq.It.isAny())) + .returns(() => [python37, python38]) + .verifiable(typemoq.Times.once()); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve(python38.executable.filename)) + .verifiable(typemoq.Times.once()); + + const deferred = createDeferred(); + let _next: undefined | ((value: Output) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: undefined, + out: { + subscribe: ( + next?: (value: Output) => void, + _error?: (error: unknown) => void, + complete?: () => void, + ) => { + _next = next; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + const promise = venvProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_next); + assert.isDefined(_complete); + + _next!({ out: `${VENV_CREATED_MARKER}new_environment`, source: 'stdout' }); + _complete!(); + assert.strictEqual(await promise, 'new_environment'); + discoveryApi.verifyAll(); + interpreterQuickPick.verifyAll(); + }); + + test('Create venv failed', async () => { + pickWorkspaceFolderStub.resolves({ + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }); + + // Return single env here to skip user selection. + discoveryApi + .setup((d) => d.getEnvs(typemoq.It.isAny())) + .returns(() => [python38]) + .verifiable(typemoq.Times.once()); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny())) + .returns(() => Promise.resolve(undefined)) + .verifiable(typemoq.Times.never()); + + const deferred = createDeferred(); + let _error: undefined | ((error: unknown) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: undefined, + out: { + subscribe: ( + _next?: (value: Output) => void, + error?: (error: unknown) => void, + complete?: () => void, + ) => { + _error = error; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + const promise = venvProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_error); + _error!('bad arguments'); + _complete!(); + await assert.isRejected(promise); + }); +});