diff --git a/extensions/positron-python/.eslintignore b/extensions/positron-python/.eslintignore index 083b9d650d0c..e1fe53377064 100644 --- a/extensions/positron-python/.eslintignore +++ b/extensions/positron-python/.eslintignore @@ -146,10 +146,8 @@ src/client/interpreter/configuration/services/workspaceUpdaterService.ts src/client/interpreter/configuration/services/workspaceFolderUpdaterService.ts src/client/interpreter/helpers.ts src/client/interpreter/virtualEnvs/condaInheritEnvPrompt.ts -src/client/interpreter/activation/service.ts src/client/interpreter/display/index.ts -src/client/api.ts src/client/extension.ts src/client/sourceMapSupport.ts src/client/startupTelemetry.ts diff --git a/extensions/positron-python/README.md b/extensions/positron-python/README.md index 48f5ffc8b551..d27ec8e762f5 100644 --- a/extensions/positron-python/README.md +++ b/extensions/positron-python/README.md @@ -9,7 +9,7 @@ The Python extension does offer [some support](https://github.com/microsoft/vsco ## Installed extensions -The Python extension will automatically install the [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance), [Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) and [isort](https://marketplace.visualstudio.com/items?itemName=ms-python.isort) extensions to give you the best experience when working with Python files and Jupyter notebooks. However, Pylance is an optional dependency, meaning the Python extension will remain fully functional if it fails to be installed. You can also [uninstall](https://code.visualstudio.com/docs/editor/extension-marketplace#_uninstall-an-extension) it at the expense of some features if you’re using a different language server. +The Python extension will automatically install the [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) and [Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) extensions to give you the best experience when working with Python files and Jupyter notebooks. However, Pylance is an optional dependency, meaning the Python extension will remain fully functional if it fails to be installed. You can also [uninstall](https://code.visualstudio.com/docs/editor/extension-marketplace#_uninstall-an-extension) it at the expense of some features if you’re using a different language server. Extensions installed through the marketplace are subject to the [Marketplace Terms of Use](https://cdn.vsassets.io/v/M146_20190123.39/_content/Microsoft-Visual-Studio-Marketplace-Terms-of-Use.pdf). @@ -37,7 +37,9 @@ Extensions installed through the marketplace are subject to the [Marketplace Ter ## Jupyter Notebook quick start -The Python extension and the [Jupyter extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) work together to give you a great Notebook experience in VS Code. +The Python extension offers support for Jupyter notebooks via the [Jupyter extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) to provide you a great Python notebook experience in VS Code. + +- Install the [Jupyter extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter). - Open or create a Jupyter Notebook file (.ipynb) and start coding in our Notebook Editor! @@ -80,7 +82,9 @@ Learn more about the rich features of the Python extension: - [Environments](https://code.visualstudio.com/docs/python/environments): Automatically activate and switch between virtualenv, venv, pipenv, conda and pyenv environments -- [Refactoring](https://code.visualstudio.com/docs/python/editing#_refactoring): Restructure your Python code with variable extraction, method extraction and import sorting +- [Refactoring](https://code.visualstudio.com/docs/python/editing#_refactoring): Restructure your Python code with variable extraction and method extraction. Additionally, there is componentized support to enable additional refactoring, such as import sorting, through extensions including [isort](https://marketplace.visualstudio.com/items?itemName=ms-python.isort) and [Ruff](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff). + + ## Supported locales diff --git a/extensions/positron-python/build/license-header.txt b/extensions/positron-python/build/license-header.txt index c95da3f6a07c..2a8122642cb2 100644 --- a/extensions/positron-python/build/license-header.txt +++ b/extensions/positron-python/build/license-header.txt @@ -3,9 +3,6 @@ PLEASE NOTE: This is the license for the Python extension for Visual Studio Code - The Jupyter extension is released under an MIT License: https://marketplace.visualstudio.com/items/ms-toolsai.jupyter/license - - The isort extension is released under an MIT License: - https://marketplace.visualstudio.com/items/ms-python.isort/license - - The Pylance extension is only available in binary form and is released under a Microsoft proprietary license, the terms of which are available here: https://marketplace.visualstudio.com/items/ms-python.vscode-pylance/license diff --git a/extensions/positron-python/build/webpack/common.js b/extensions/positron-python/build/webpack/common.js index 81ba8ec04fba..b248b29fdd69 100644 --- a/extensions/positron-python/build/webpack/common.js +++ b/extensions/positron-python/build/webpack/common.js @@ -20,8 +20,6 @@ exports.nodeModulesToExternalize = [ 'unicode/category/Mc', 'unicode/category/Nd', 'unicode/category/Pc', - 'request', - 'request-progress', 'source-map-support', 'diff-match-patch', 'sudo-prompt', diff --git a/extensions/positron-python/gulpfile.js b/extensions/positron-python/gulpfile.js index 2949490abd66..a344b165a6cc 100644 --- a/extensions/positron-python/gulpfile.js +++ b/extensions/positron-python/gulpfile.js @@ -82,9 +82,13 @@ async function addExtensionPackDependencies() { // extension dependencies need not be installed during development const packageJsonContents = await fsExtra.readFile('package.json', 'utf-8'); const packageJson = JSON.parse(packageJsonContents); - packageJson.extensionPack = ['ms-toolsai.jupyter', 'ms-python.vscode-pylance'].concat( + packageJson.extensionPack = ['ms-python.vscode-pylance'].concat( packageJson.extensionPack ? packageJson.extensionPack : [], ); + // Remove potential duplicates. + packageJson.extensionPack = packageJson.extensionPack.filter( + (item, index) => packageJson.extensionPack.indexOf(item) === index, + ); await fsExtra.writeFile('package.json', JSON.stringify(packageJson, null, 4), 'utf-8'); } diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index d713849c3929..8adbf0acd1f3 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -18,7 +18,9 @@ }, "publisher": "ms-python", "enabledApiProposals": [ + "contribEditorContentMenu", "quickPickSortByLabel", + "envShellEvent", "testObserver" ], "author": { @@ -40,7 +42,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.75.0-20230123" + "vscode": "^1.77.0-20230309" }, "keywords": [ "python", @@ -82,7 +84,7 @@ "walkthroughs": [ { "id": "pythonWelcome", - "title": "Get started with Python development", + "title": "Get Started with Python Development", "description": "Your first steps to set up a Python project with all the powerful tools and features that the Python extension has to offer!", "when": "workspacePlatform != webworker", "steps": [ @@ -169,7 +171,7 @@ }, { "id": "pythonDataScienceWelcome", - "title": "Get started with Python for Data Science", + "title": "Get Started with Python for Data Science", "description": "Your first steps to getting started with a Data Science project with Python!", "when": "false", "steps": [ @@ -433,7 +435,14 @@ "enum": [ "All", "pythonSurveyNotification", - "pythonPromptNewToolsExt" + "pythonPromptNewToolsExt", + "pythonTerminalEnvVarActivation" + ], + "enumDescriptions": [ + "%python.experiments.All.description%", + "%python.experiments.pythonSurveyNotification.description%", + "%python.experiments.pythonPromptNewToolsExt.description%", + "%python.experiments.pythonTerminalEnvVarActivation.description%" ] }, "scope": "machine", @@ -447,7 +456,14 @@ "enum": [ "All", "pythonSurveyNotification", - "pythonPromptNewToolsExt" + "pythonPromptNewToolsExt", + "pythonTerminalEnvVarActivation" + ], + "enumDescriptions": [ + "%python.experiments.All.description%", + "%python.experiments.pythonSurveyNotification.description%", + "%python.experiments.pythonPromptNewToolsExt.description%", + "%python.experiments.pythonTerminalEnvVarActivation.description%" ] }, "scope": "machine", @@ -1680,6 +1696,18 @@ "when": "!virtualWorkspace && shellExecutionSupported" } ], + "editor/content": [ + { + "group": "Python", + "command": "python.createEnvironment", + "when": "resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported" + }, + { + "group": "Python", + "command": "python.createEnvironment", + "when": "resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported" + } + ], "editor/context": [ { "command": "python.execInTerminal", @@ -1823,8 +1851,6 @@ "node-stream-zip": "^1.6.0", "portfinder": "^1.0.28", "reflect-metadata": "^0.1.12", - "request": "^2.87.0", - "request-progress": "^3.0.0", "rxjs": "^6.5.4", "rxjs-compat": "^6.5.4", "semver": "^5.5.0", @@ -1847,6 +1873,7 @@ }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@types/bent": "^7.3.0", "@types/chai": "^4.1.2", "@types/chai-arrays": "^2.0.0", "@types/chai-as-promised": "^7.1.0", @@ -1859,20 +1886,20 @@ "@types/mocha": "^9.1.0", "@types/nock": "^10.0.3", "@types/node": "^14.18.0", - "@types/request": "^2.47.0", "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^10.0.11", "@types/stack-trace": "0.0.29", "@types/tmp": "^0.0.33", "@types/uuid": "^8.3.4", - "@types/vscode": "1.74.0", + "@types/vscode": "^1.75.0", "@types/which": "^2.0.1", "@types/winreg": "^1.2.30", "@types/xml2js": "0.4.9", "@typescript-eslint/eslint-plugin": "^3.7.0", "@typescript-eslint/parser": "^3.7.0", "@vscode/test-electron": "^2.1.3", + "bent": "^7.3.12", "chai": "^4.1.2", "chai-arrays": "^2.0.0", "chai-as-promised": "^7.1.1", @@ -1916,7 +1943,7 @@ "uuid": "^8.3.2", "vsce": "^2.6.6", "vscode-debugadapter-testsupport": "^1.27.0", - "webpack": "^5.70.0", + "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.9.2", "webpack-fix-default-import-plugin": "^1.0.3", diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index 75f200d3fc97..59aaac152ad4 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -3,7 +3,7 @@ "description": "IntelliSense (Pylance), Linting, Debugging (multi-threaded, remote), Jupyter Notebooks, code formatting, refactoring, unit tests, and more.", "python.command.python.sortImports.title": "Sort Imports", "python.command.python.startREPL.title": "Start REPL", - "python.command.python.createEnvironment.title": "Create Environment", + "python.command.python.createEnvironment.title": "Create Environment...", "python.command.python.createNewFile.title": "New Python File", "python.command.python.createTerminal.title": "Create Terminal", "python.command.python.execInTerminal.title": "Run Python File in Terminal", @@ -36,6 +36,10 @@ "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 [here](https://github.com/microsoft/vscode-python/wiki/AB-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 [here](https://github.com/microsoft/vscode-python/wiki/AB-Experiments) for more details.", + "python.experiments.All.description": "Combined list of all experiments.", + "python.experiments.pythonSurveyNotification.description": "Denotes the Python Survey Notification experiment.", + "python.experiments.pythonPromptNewToolsExt.description": "Denotes the Python Prompt New Tools Extension experiment.", + "python.experiments.pythonTerminalEnvVarActivation.description": "Enables use of environment variables to activate terminals instead of sending activation commands.", "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.", @@ -101,7 +105,7 @@ "python.sortImports.path.description": "Path to isort script, default using inner version", "python.tensorBoard.logDirectory.description": "Set this setting to your preferred TensorBoard log directory to skip log directory prompt when starting TensorBoard.", "python.terminal.activateEnvInCurrentTerminal.description": "Activate Python Environment in the current Terminal on load of the Extension.", - "python.terminal.activateEnvironment.description": "Activate Python Environment in Terminal created using the Extension.", + "python.terminal.activateEnvironment.description": "Activate Python Environment in all Terminals created.", "python.terminal.executeInFileDir.description": "When executing a file in the terminal, whether to use execute in the file's directory, instead of the current open folder.", "python.terminal.focusAfterLaunch.description": "When launching a python terminal, whether to focus the cursor on the terminal.", "python.terminal.launchArgs.description": "Python launch arguments to use when executing a file in the terminal.", diff --git a/extensions/positron-python/pythonFiles/create_venv.py b/extensions/positron-python/pythonFiles/create_venv.py index a97199c4c6c6..2a2768c993ae 100644 --- a/extensions/positron-python/pythonFiles/create_venv.py +++ b/extensions/positron-python/pythonFiles/create_venv.py @@ -90,14 +90,12 @@ 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(f"VENV_INSTALLING_REQUIREMENTS: {requirement}") + run_process( + [venv_path, "-m", "pip", "install", "-r", requirement], + "CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS", + ) print("CREATE_VENV.PIP_INSTALLED_REQUIREMENTS") @@ -111,10 +109,12 @@ def install_toml(venv_path: str, extras: List[str]) -> None: def upgrade_pip(venv_path: str) -> None: + print("CREATE_VENV.UPGRADING_PIP") run_process( [venv_path, "-m", "pip", "install", "--upgrade", "pip"], - "CREATE_VENV.PIP_UPGRADE_FAILED", + "CREATE_VENV.UPGRADE_PIP_FAILED", ) + print("CREATE_VENV.UPGRADED_PIP") def add_gitignore(name: str) -> None: diff --git a/extensions/positron-python/pythonFiles/tests/test_create_venv.py b/extensions/positron-python/pythonFiles/tests/test_create_venv.py index e70a4d90c99b..95ec863373d8 100644 --- a/extensions/positron-python/pythonFiles/tests/test_create_venv.py +++ b/extensions/positron-python/pythonFiles/tests/test_create_venv.py @@ -100,7 +100,7 @@ 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" + assert error_message == "CREATE_VENV.UPGRADE_PIP_FAILED" elif args[1:-1] == ["-m", "pip", "install", "-r"]: installing = "requirements" assert error_message == "CREATE_VENV.PIP_FAILED_INSTALL_REQUIREMENTS" @@ -146,22 +146,16 @@ def run_process(args, error_message): @pytest.mark.parametrize( ("extras", "expected"), [ - ([], None), + ([], []), ( ["requirements/test.txt"], - [sys.executable, "-m", "pip", "install", "-r", "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", + [sys.executable, "-m", "pip", "install", "-r", "requirements/test.txt"], + [sys.executable, "-m", "pip", "install", "-r", "requirements/doc.txt"], ], ), ], @@ -169,11 +163,11 @@ def run_process(args, error_message): def test_requirements_args(extras, expected): importlib.reload(create_venv) - actual = None + actual = [] def run_process(args, error_message): nonlocal actual - actual = args + actual.append(args) create_venv.run_process = run_process diff --git a/extensions/positron-python/requirements.in b/extensions/positron-python/requirements.in index 0acff6b4efab..c394c0feb0cf 100644 --- a/extensions/positron-python/requirements.in +++ b/extensions/positron-python/requirements.in @@ -4,4 +4,4 @@ # 2) pip-compile --generate-hashes requirements.in # Unittest test adapter -typing-extensions==4.4.0 +typing-extensions==4.5.0 diff --git a/extensions/positron-python/requirements.txt b/extensions/positron-python/requirements.txt index 1f26087deb2b..1cdb049c430a 100644 --- a/extensions/positron-python/requirements.txt +++ b/extensions/positron-python/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --generate-hashes requirements.in # -typing-extensions==4.4.0 \ - --hash=sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa \ - --hash=sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e +typing-extensions==4.5.0 \ + --hash=sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb \ + --hash=sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4 # via -r requirements.in diff --git a/extensions/positron-python/src/client/activation/node/analysisOptions.ts b/extensions/positron-python/src/client/activation/node/analysisOptions.ts index f717a8e2f3f1..80dbc53a59ee 100644 --- a/extensions/positron-python/src/client/activation/node/analysisOptions.ts +++ b/extensions/positron-python/src/client/activation/node/analysisOptions.ts @@ -10,7 +10,6 @@ import { IExperimentService } from '../../common/types'; import { LanguageServerAnalysisOptionsBase } from '../common/analysisOptions'; import { ILanguageServerOutputChannel } from '../types'; -import { LspNotebooksExperiment } from './lspNotebooksExperiment'; import { traceWarn } from '../../logging'; const EDITOR_CONFIG_SECTION = 'editor'; @@ -22,7 +21,6 @@ export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOpt lsOutputChannel: ILanguageServerOutputChannel, workspace: IWorkspaceService, private readonly experimentService: IExperimentService, - private readonly lspNotebooksExperiment: LspNotebooksExperiment, ) { super(lsOutputChannel, workspace); } @@ -36,8 +34,6 @@ export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOpt return ({ experimentationSupport: true, trustedWorkspaceSupport: true, - lspNotebooksSupport: this.lspNotebooksExperiment.isInNotebooksExperiment(), - lspInteractiveWindowSupport: this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport(), autoIndentSupport: await this.isAutoIndentEnabled(), } as unknown) as LanguageClientOptions; } diff --git a/extensions/positron-python/src/client/activation/node/languageClientFactory.ts b/extensions/positron-python/src/client/activation/node/languageClientFactory.ts index ef2ac872c190..9543f265468f 100644 --- a/extensions/positron-python/src/client/activation/node/languageClientFactory.ts +++ b/extensions/positron-python/src/client/activation/node/languageClientFactory.ts @@ -11,7 +11,7 @@ import { PythonEnvironment } from '../../pythonEnvironments/info'; import { FileBasedCancellationStrategy } from '../common/cancellationUtils'; import { ILanguageClientFactory } from '../types'; -const languageClientName = 'Pylance'; +export const PYLANCE_NAME = 'Pylance'; export class NodeLanguageClientFactory implements ILanguageClientFactory { constructor(private readonly fs: IFileSystem, private readonly extensions: IExtensions) {} @@ -50,6 +50,6 @@ export class NodeLanguageClientFactory implements ILanguageClientFactory { }, }; - return new LanguageClient(PYTHON_LANGUAGE, languageClientName, serverOptions, clientOptions); + return new LanguageClient(PYTHON_LANGUAGE, PYLANCE_NAME, serverOptions, clientOptions); } } diff --git a/extensions/positron-python/src/client/activation/node/languageClientMiddleware.ts b/extensions/positron-python/src/client/activation/node/languageClientMiddleware.ts index 9c1d4c468191..fbc534f17e1c 100644 --- a/extensions/positron-python/src/client/activation/node/languageClientMiddleware.ts +++ b/extensions/positron-python/src/client/activation/node/languageClientMiddleware.ts @@ -36,7 +36,7 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { this.jupyterExtensionIntegration = serviceContainer.get( JupyterExtensionIntegration, ); - if (!this.notebookAddon && this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) { + if (!this.notebookAddon) { this.notebookAddon = new LspInteractiveWindowMiddlewareAddon( this.getClient, this.jupyterExtensionIntegration, @@ -44,11 +44,9 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { } } - protected shouldCreateHidingMiddleware(jupyterDependencyManager: IJupyterExtensionDependencyManager): boolean { - return ( - super.shouldCreateHidingMiddleware(jupyterDependencyManager) && - !this.lspNotebooksExperiment.isInNotebooksExperiment() - ); + // eslint-disable-next-line class-methods-use-this + protected shouldCreateHidingMiddleware(_: IJupyterExtensionDependencyManager): boolean { + return false; } protected async onExtensionChange(jupyterDependencyManager: IJupyterExtensionDependencyManager): Promise { @@ -56,20 +54,16 @@ export class NodeLanguageClientMiddleware extends LanguageClientMiddleware { await this.lspNotebooksExperiment.onJupyterInstalled(); } - if (this.lspNotebooksExperiment.isInNotebooksExperimentWithInteractiveWindowSupport()) { - if (!this.notebookAddon) { - this.notebookAddon = new LspInteractiveWindowMiddlewareAddon( - this.getClient, - this.jupyterExtensionIntegration, - ); - } - } else { - super.onExtensionChange(jupyterDependencyManager); + if (!this.notebookAddon) { + this.notebookAddon = new LspInteractiveWindowMiddlewareAddon( + this.getClient, + this.jupyterExtensionIntegration, + ); } } protected async getPythonPathOverride(uri: Uri | undefined): Promise { - if (!uri || !this.lspNotebooksExperiment.isInNotebooksExperiment()) { + if (!uri) { return undefined; } diff --git a/extensions/positron-python/src/client/activation/node/languageServerProxy.ts b/extensions/positron-python/src/client/activation/node/languageServerProxy.ts index dea261514702..45d1d1a17fee 100644 --- a/extensions/positron-python/src/client/activation/node/languageServerProxy.ts +++ b/extensions/positron-python/src/client/activation/node/languageServerProxy.ts @@ -9,6 +9,7 @@ import { LanguageClientOptions, } from 'vscode-languageclient/node'; +import { Extension } from 'vscode'; import { IExperimentService, IExtensions, IInterpreterPathService, Resource } from '../../common/types'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { PythonEnvironment } from '../../pythonEnvironments/info'; @@ -20,6 +21,7 @@ import { ILanguageClientFactory, ILanguageServerProxy } from '../types'; import { traceDecoratorError, traceDecoratorVerbose, traceError } from '../../logging'; import { IWorkspaceService } from '../../common/application/types'; import { PYLANCE_EXTENSION_ID } from '../../common/constants'; +import { PylanceApi } from './pylanceApi'; // eslint-disable-next-line @typescript-eslint/no-namespace namespace InExperiment { @@ -56,6 +58,8 @@ export class NodeLanguageServerProxy implements ILanguageServerProxy { private lsVersion: string | undefined; + private pylanceApi: PylanceApi | undefined; + constructor( private readonly factory: ILanguageClientFactory, private readonly experimentService: IExperimentService, @@ -89,9 +93,16 @@ export class NodeLanguageServerProxy implements ILanguageServerProxy { interpreter: PythonEnvironment | undefined, options: LanguageClientOptions, ): Promise { - const extension = this.extensions.getExtension(PYLANCE_EXTENSION_ID); + const extension = await this.getPylanceExtension(); this.lsVersion = extension?.packageJSON.version || '0'; + const api = extension?.exports; + if (api && api.client && api.client.isEnabled()) { + this.pylanceApi = api; + await api.client.start(); + return; + } + this.cancellationStrategy = new FileBasedCancellationStrategy(); options.connectionOptions = { cancellationStrategy: this.cancellationStrategy }; @@ -111,6 +122,12 @@ export class NodeLanguageServerProxy implements ILanguageServerProxy { @traceDecoratorVerbose('Disposing language server') public async stop(): Promise { + if (this.pylanceApi) { + const api = this.pylanceApi; + this.pylanceApi = undefined; + await api.client!.stop(); + } + while (this.disposables.length > 0) { const d = this.disposables.shift()!; d.dispose(); @@ -203,4 +220,17 @@ export class NodeLanguageServerProxy implements ILanguageServerProxy { })), ); } + + private async getPylanceExtension(): Promise | undefined> { + const extension = this.extensions.getExtension(PYLANCE_EXTENSION_ID); + if (!extension) { + return undefined; + } + + if (!extension.isActive) { + await extension.activate(); + } + + return extension; + } } diff --git a/extensions/positron-python/src/client/activation/node/lspNotebooksExperiment.ts b/extensions/positron-python/src/client/activation/node/lspNotebooksExperiment.ts index d469cfb112df..de0acde0600e 100644 --- a/extensions/positron-python/src/client/activation/node/lspNotebooksExperiment.ts +++ b/extensions/positron-python/src/client/activation/node/lspNotebooksExperiment.ts @@ -1,16 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import { inject, injectable } from 'inversify'; -import * as semver from 'semver'; -import { Disposable, extensions } from 'vscode'; -import { IConfigurationService, IDisposableRegistry } from '../../common/types'; -import { sendTelemetryEvent } from '../../telemetry'; -import { EventName } from '../../telemetry/constants'; -import { JUPYTER_EXTENSION_ID, PYLANCE_EXTENSION_ID } from '../../common/constants'; -import { IExtensionSingleActivationService, LanguageServerType } from '../types'; -import { traceLog, traceVerbose } from '../../logging'; +import { IExtensionSingleActivationService } from '../types'; +import { traceVerbose } from '../../logging'; import { IJupyterExtensionDependencyManager } from '../../common/application/types'; -import { ILanguageServerWatcher } from '../../languageServer/types'; import { IServiceContainer } from '../../ioc/types'; import { sleep } from '../../common/utils/async'; import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration'; @@ -19,30 +12,18 @@ import { JupyterExtensionIntegration } from '../../jupyter/jupyterIntegration'; export class LspNotebooksExperiment implements IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: true, virtualWorkspace: true }; - private pylanceExtensionChangeHandler: Disposable | undefined; - private isJupyterInstalled = false; - private isInExperiment: boolean | undefined; - - private supportsInteractiveWindow: boolean | undefined; - constructor( @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer, - @inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry, @inject(IJupyterExtensionDependencyManager) jupyterDependencyManager: IJupyterExtensionDependencyManager, ) { this.isJupyterInstalled = jupyterDependencyManager.isJupyterExtensionInstalled; } - public async activate(): Promise { - if (!LspNotebooksExperiment.isPylanceInstalled()) { - this.pylanceExtensionChangeHandler = extensions.onDidChange(this.pylanceExtensionsChangeHandler.bind(this)); - this.disposables.push(this.pylanceExtensionChangeHandler); - } - - this.updateExperimentSupport(); + // eslint-disable-next-line class-methods-use-this + public activate(): Promise { + return Promise.resolve(); } public async onJupyterInstalled(): Promise { @@ -50,103 +31,11 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService return; } - if (LspNotebooksExperiment.jupyterSupportsNotebooksExperiment()) { - await this.waitForJupyterToRegisterPythonPathFunction(); - this.updateExperimentSupport(); - } + await this.waitForJupyterToRegisterPythonPathFunction(); this.isJupyterInstalled = true; } - public isInNotebooksExperiment(): boolean { - return this.isInExperiment ?? false; - } - - public isInNotebooksExperimentWithInteractiveWindowSupport(): boolean { - return this.supportsInteractiveWindow ?? false; - } - - private updateExperimentSupport(): void { - const wasInExperiment = this.isInExperiment; - const isInTreatmentGroup = true; - const languageServerType = this.configurationService.getSettings().languageServer; - - this.isInExperiment = false; - if (languageServerType !== LanguageServerType.Node) { - traceLog(`LSP Notebooks experiment is disabled -- not using Pylance`); - } else if (!LspNotebooksExperiment.isJupyterInstalled()) { - traceLog(`LSP Notebooks experiment is disabled -- Jupyter disabled or not installed`); - } else if (!LspNotebooksExperiment.jupyterSupportsNotebooksExperiment()) { - traceLog(`LSP Notebooks experiment is disabled -- Jupyter does not support experiment`); - } else if (!LspNotebooksExperiment.isPylanceInstalled()) { - traceLog(`LSP Notebooks experiment is disabled -- Pylance disabled or not installed`); - } else if (!LspNotebooksExperiment.pylanceSupportsNotebooksExperiment()) { - traceLog(`LSP Notebooks experiment is disabled -- Pylance does not support experiment`); - } else if (!isInTreatmentGroup) { - traceLog(`LSP Notebooks experiment is disabled -- not in treatment group`); - // to avoid scorecard SRMs, we're also triggering the telemetry for users who meet - // the criteria to experience LSP notebooks, but may be in the control group. - sendTelemetryEvent(EventName.PYTHON_EXPERIMENTS_LSP_NOTEBOOKS); - } else { - this.isInExperiment = true; - traceLog(`LSP Notebooks experiment is enabled`); - sendTelemetryEvent(EventName.PYTHON_EXPERIMENTS_LSP_NOTEBOOKS); - } - - this.supportsInteractiveWindow = false; - if (!this.isInExperiment) { - traceLog(`LSP Notebooks interactive window support is disabled -- not in LSP Notebooks experiment`); - } else if (!LspNotebooksExperiment.jupyterSupportsLspInteractiveWindow()) { - traceLog(`LSP Notebooks interactive window support is disabled -- Jupyter is not new enough`); - } else if (!LspNotebooksExperiment.pylanceSupportsLspInteractiveWindow()) { - traceLog(`LSP Notebooks interactive window support is disabled -- Pylance is not new enough`); - } else { - this.supportsInteractiveWindow = true; - traceLog(`LSP Notebooks interactive window support is enabled`); - } - - // Our "in experiment" status can only change from false to true. That's possible if Pylance - // or Jupyter is installed after Python is activated. A true to false transition would require - // either Pylance or Jupyter to be uninstalled or downgraded after Python activated, and that - // would require VS Code to be reloaded before the new extension version could be used. - if (wasInExperiment === false && this.isInExperiment === true) { - const watcher = this.serviceContainer.get(ILanguageServerWatcher); - if (watcher) { - watcher.restartLanguageServers(); - } - } - } - - private static jupyterSupportsNotebooksExperiment(): boolean { - const jupyterVersion = extensions.getExtension(JUPYTER_EXTENSION_ID)?.packageJSON.version; - return ( - jupyterVersion && (semver.gt(jupyterVersion, '2022.5.1001411044') || semver.patch(jupyterVersion) === 100) - ); - } - - private static pylanceSupportsNotebooksExperiment(): boolean { - const pylanceVersion = extensions.getExtension(PYLANCE_EXTENSION_ID)?.packageJSON.version; - return ( - pylanceVersion && - (semver.gte(pylanceVersion, '2022.5.3-pre.1') || semver.prerelease(pylanceVersion)?.includes('dev')) - ); - } - - private static jupyterSupportsLspInteractiveWindow(): boolean { - const jupyterVersion = extensions.getExtension(JUPYTER_EXTENSION_ID)?.packageJSON.version; - return ( - jupyterVersion && (semver.gt(jupyterVersion, '2022.7.1002041057') || semver.patch(jupyterVersion) === 100) - ); - } - - private static pylanceSupportsLspInteractiveWindow(): boolean { - const pylanceVersion = extensions.getExtension(PYLANCE_EXTENSION_ID)?.packageJSON.version; - return ( - pylanceVersion && - (semver.gte(pylanceVersion, '2022.7.51') || semver.prerelease(pylanceVersion)?.includes('dev')) - ); - } - private async waitForJupyterToRegisterPythonPathFunction(): Promise { const jupyterExtensionIntegration = this.serviceContainer.get( JupyterExtensionIntegration, @@ -168,21 +57,4 @@ export class LspNotebooksExperiment implements IExtensionSingleActivationService traceVerbose(`Timed out waiting for Jupyter to call registerJupyterPythonPathFunction`); } } - - private static isPylanceInstalled(): boolean { - return !!extensions.getExtension(PYLANCE_EXTENSION_ID); - } - - private static isJupyterInstalled(): boolean { - return !!extensions.getExtension(JUPYTER_EXTENSION_ID); - } - - private async pylanceExtensionsChangeHandler(): Promise { - if (LspNotebooksExperiment.isPylanceInstalled() && this.pylanceExtensionChangeHandler) { - this.pylanceExtensionChangeHandler.dispose(); - this.pylanceExtensionChangeHandler = undefined; - - this.updateExperimentSupport(); - } - } } diff --git a/extensions/positron-python/src/client/activation/node/manager.ts b/extensions/positron-python/src/client/activation/node/manager.ts index 85d57622e327..b85d8fe6ed14 100644 --- a/extensions/positron-python/src/client/activation/node/manager.ts +++ b/extensions/positron-python/src/client/activation/node/manager.ts @@ -7,7 +7,7 @@ import { IDisposable, IExtensions, Resource } from '../../common/types'; import { debounceSync } from '../../common/utils/decorators'; import { IServiceContainer } from '../../ioc/types'; import { PythonEnvironment } from '../../pythonEnvironments/info'; -import { captureTelemetry } from '../../telemetry'; +import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { Commands } from '../commands'; import { NodeLanguageClientMiddleware } from './languageClientMiddleware'; @@ -44,6 +44,7 @@ export class NodeLanguageServerManager implements ILanguageServerManager { NodeLanguageServerManager.commandDispose.dispose(); } NodeLanguageServerManager.commandDispose = commandManager.registerCommand(Commands.RestartLS, () => { + sendTelemetryEvent(EventName.LANGUAGE_SERVER_RESTART, undefined, { reason: 'command' }); this.restartLanguageServer().ignoreErrors(); }); } @@ -94,6 +95,7 @@ export class NodeLanguageServerManager implements ILanguageServerManager { @debounceSync(1000) protected restartLanguageServerDebounced(): void { + sendTelemetryEvent(EventName.LANGUAGE_SERVER_RESTART, undefined, { reason: 'settings' }); this.restartLanguageServer().ignoreErrors(); } diff --git a/extensions/positron-python/src/client/activation/node/pylanceApi.ts b/extensions/positron-python/src/client/activation/node/pylanceApi.ts new file mode 100644 index 000000000000..72f20db140e4 --- /dev/null +++ b/extensions/positron-python/src/client/activation/node/pylanceApi.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { + CancellationToken, + CompletionContext, + CompletionItem, + CompletionList, + Position, + TextDocument, + Uri, +} from 'vscode'; + +export interface PylanceApi { + client?: { + isEnabled(): boolean; + start(): Promise; + stop(): Promise; + }; + notebook?: { + registerJupyterPythonPathFunction(func: (uri: Uri) => Promise): void; + registerGetNotebookUriForTextDocumentUriFunction(func: (textDocumentUri: Uri) => Uri | undefined): void; + getCompletionItems( + document: TextDocument, + position: Position, + context: CompletionContext, + token: CancellationToken, + ): Promise; + }; +} diff --git a/extensions/positron-python/src/client/api.ts b/extensions/positron-python/src/client/api.ts index 78663feab712..7bc3fc81373b 100644 --- a/extensions/positron-python/src/client/api.ts +++ b/extensions/positron-python/src/client/api.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. @@ -5,34 +6,77 @@ import { noop } from 'lodash'; import { Uri, Event } from 'vscode'; +import { BaseLanguageClient, LanguageClientOptions } from 'vscode-languageclient'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { PYLANCE_NAME } from './activation/node/languageClientFactory'; +import { ILanguageServerOutputChannel } from './activation/types'; import { IExtensionApi } from './apiTypes'; -import { isTestExecution } from './common/constants'; +import { isTestExecution, PYTHON_LANGUAGE } from './common/constants'; import { IConfigurationService, Resource } from './common/types'; -import { IEnvironmentVariablesProvider } from './common/variables/types'; import { getDebugpyLauncherArgs, getDebugpyPackagePath } from './debugger/extension/adapter/remoteLaunchers'; import { IInterpreterService } from './interpreter/contracts'; import { IServiceContainer, IServiceManager } from './ioc/types'; import { JupyterExtensionIntegration } from './jupyter/jupyterIntegration'; +import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types'; import { traceError } from './logging'; +import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { buildEnvironmentApi } from './environmentApi'; +import { ApiForPylance } from './pylanceApi'; +import { getTelemetryReporter } from './telemetry'; export function buildApi( - ready: Promise, + ready: Promise, serviceManager: IServiceManager, serviceContainer: IServiceContainer, + discoveryApi: IDiscoveryAPI, ): IExtensionApi { const configurationService = serviceContainer.get(IConfigurationService); const interpreterService = serviceContainer.get(IInterpreterService); serviceManager.addSingleton(JupyterExtensionIntegration, JupyterExtensionIntegration); const jupyterIntegration = serviceContainer.get(JupyterExtensionIntegration); - const envService = serviceContainer.get(IEnvironmentVariablesProvider); + const outputChannel = serviceContainer.get(ILanguageServerOutputChannel); + const api: IExtensionApi & { /** * @deprecated Temporarily exposed for Pylance until we expose this API generally. Will be removed in an * iteration or two. */ - pylance: { - getPythonPathVar: (resource?: Uri) => Promise; - readonly onDidEnvironmentVariablesChange: Event; + pylance: ApiForPylance; + } & { + /** + * @deprecated Use IExtensionApi.environments API instead. + * + * Return internal settings within the extension which are stored in VSCode storage + */ + settings: { + /** + * An event that is emitted when execution details (for a resource) change. For instance, when interpreter configuration changes. + */ + readonly onDidChangeExecutionDetails: Event; + /** + * Returns all the details the consumer needs to execute code within the selected environment, + * corresponding to the specified resource taking into account any workspace-specific settings + * for the workspace to which this resource belongs. + * @param {Resource} [resource] A resource for which the setting is asked for. + * * When no resource is provided, the setting scoped to the first workspace folder is returned. + * * If no folder is present, it returns the global setting. + * @returns {({ execCommand: string[] | undefined })} + */ + getExecutionDetails( + resource?: Resource, + ): { + /** + * E.g of execution commands returned could be, + * * `['']` + * * `['']` + * * `['conda', 'run', 'python']` which is used to run from within Conda environments. + * or something similar for some other Python environments. + * + * @type {(string[] | undefined)} When return value is `undefined`, it means no interpreter is set. + * Otherwise, join the items returned using space to construct the full execution command. + */ + execCommand: string[] | undefined; + }; }; } = { // 'ready' will propagate the exception, but we must log it here first. @@ -47,7 +91,7 @@ export function buildApi( async getRemoteLauncherCommand( host: string, port: number, - waitUntilDebuggerAttaches: boolean = true, + waitUntilDebuggerAttaches = true, ): Promise { return getDebugpyLauncherArgs({ host, @@ -62,7 +106,7 @@ export function buildApi( settings: { onDidChangeExecutionDetails: interpreterService.onDidChangeInterpreterConfiguration, getExecutionDetails(resource?: Resource) { - const pythonPath = configurationService.getSettings(resource).pythonPath; + const { pythonPath } = configurationService.getSettings(resource); // If pythonPath equals an empty string, no interpreter is set. return { execCommand: pythonPath === '' ? undefined : [pythonPath] }; }, @@ -72,18 +116,25 @@ export function buildApi( datascience: { registerRemoteServerProvider: jupyterIntegration ? jupyterIntegration.registerRemoteServerProvider.bind(jupyterIntegration) - : (noop as any), + : ((noop as unknown) as (serverProvider: IJupyterUriProvider) => void), showDataViewer: jupyterIntegration ? jupyterIntegration.showDataViewer.bind(jupyterIntegration) - : (noop as any), + : ((noop as unknown) as (dataProvider: IDataViewerDataProvider, title: string) => Promise), }, pylance: { - getPythonPathVar: async (resource?: Uri) => { - const envs = await envService.getEnvironmentVariables(resource); - return envs.PYTHONPATH; + createClient: (...args: any[]): BaseLanguageClient => { + // Make sure we share output channel so that we can share one with + // Jedi as well. + const clientOptions = args[1] as LanguageClientOptions; + clientOptions.outputChannel = clientOptions.outputChannel ?? outputChannel.channel; + + return new LanguageClient(PYTHON_LANGUAGE, PYLANCE_NAME, args[0], clientOptions); }, - onDidEnvironmentVariablesChange: envService.onDidEnvironmentVariablesChange, + start: (client: BaseLanguageClient): Promise => client.start(), + stop: (client: BaseLanguageClient): Promise => client.stop(), + getTelemetryReporter: () => getTelemetryReporter(), }, + environments: buildEnvironmentApi(discoveryApi, serviceContainer), }; // In test environment return the DI Container. diff --git a/extensions/positron-python/src/client/apiTypes.ts b/extensions/positron-python/src/client/apiTypes.ts index a10fd2dccb96..d30a81582a7e 100644 --- a/extensions/positron-python/src/client/apiTypes.ts +++ b/extensions/positron-python/src/client/apiTypes.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Event, Uri } from 'vscode'; -import { Resource } from './common/types'; +import { CancellationToken, Event, Uri, WorkspaceFolder } from 'vscode'; import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types'; /* @@ -38,39 +37,6 @@ export interface IExtensionApi { */ getDebuggerPackagePath(): Promise; }; - /** - * Return internal settings within the extension which are stored in VSCode storage - */ - settings: { - /** - * An event that is emitted when execution details (for a resource) change. For instance, when interpreter configuration changes. - */ - readonly onDidChangeExecutionDetails: Event; - /** - * Returns all the details the consumer needs to execute code within the selected environment, - * corresponding to the specified resource taking into account any workspace-specific settings - * for the workspace to which this resource belongs. - * @param {Resource} [resource] A resource for which the setting is asked for. - * * When no resource is provided, the setting scoped to the first workspace folder is returned. - * * If no folder is present, it returns the global setting. - * @returns {({ execCommand: string[] | undefined })} - */ - getExecutionDetails( - resource?: Resource, - ): { - /** - * E.g of execution commands returned could be, - * * `['']` - * * `['']` - * * `['conda', 'run', 'python']` which is used to run from within Conda environments. - * or something similar for some other Python environments. - * - * @type {(string[] | undefined)} When return value is `undefined`, it means no interpreter is set. - * Otherwise, join the items returned using space to construct the full execution command. - */ - execCommand: string[] | undefined; - }; - }; datascience: { /** @@ -85,4 +51,301 @@ export interface IExtensionApi { */ registerRemoteServerProvider(serverProvider: IJupyterUriProvider): void; }; + + /** + * These APIs provide a way for extensions to work with by python environments available in the user's machine + * as found by the Python extension. See + * https://github.com/microsoft/vscode-python/wiki/Python-Environment-APIs for usage examples and more. + */ + readonly environments: { + /** + * Returns the environment configured by user in settings. Note that this can be an invalid environment, use + * {@link resolveEnvironment} to get full details. + * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root + * scenario. If `undefined`, then the API returns what ever is set for the workspace. + */ + getActiveEnvironmentPath(resource?: Resource): EnvironmentPath; + /** + * Sets the active environment path for the python extension for the resource. Configuration target will always + * be the workspace folder. + * @param environment : If string, it represents the full path to environment folder or python executable + * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. + * @param resource : [optional] File or workspace to scope to a particular workspace folder. + */ + updateActiveEnvironmentPath( + environment: string | EnvironmentPath | Environment, + resource?: Resource, + ): Promise; + /** + * This event is triggered when the active environment setting changes. + */ + readonly onDidChangeActiveEnvironmentPath: Event; + /** + * Carries environments known to the extension at the time of fetching the property. Note this may not + * contain all environments in the system as a refresh might be going on. + * + * Only reports environments in the current workspace. + */ + readonly known: readonly Environment[]; + /** + * This event is triggered when the known environment list changes, like when a environment + * is found, existing environment is removed, or some details changed on an environment. + */ + readonly onDidChangeEnvironments: Event; + /** + * This API will trigger environment discovery, but only if it has not already happened in this VSCode session. + * Useful for making sure env list is up-to-date when the caller needs it for the first time. + * + * To force trigger a refresh regardless of whether a refresh was already triggered, see option + * {@link RefreshOptions.forceRefresh}. + * + * Note that if there is a refresh already going on then this returns the promise for that refresh. + * @param options Additional options for refresh. + * @param token A cancellation token that indicates a refresh is no longer needed. + */ + refreshEnvironments(options?: RefreshOptions, token?: CancellationToken): Promise; + /** + * Returns details for the given environment, or `undefined` if the env is invalid. + * @param environment : If string, it represents the full path to environment folder or python executable + * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. + */ + resolveEnvironment( + environment: Environment | EnvironmentPath | string, + ): Promise; + /** + * Returns the environment variables used by the extension for a resource, which includes the custom + * variables configured by user in `.env` files. + * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root + * scenario. If `undefined`, then the API returns what ever is set for the workspace. + */ + getEnvironmentVariables(resource?: Resource): EnvironmentVariables; + /** + * This event is fired when the environment variables for a resource change. Note it's currently not + * possible to detect if environment variables in the system change, so this only fires if custom + * environment variables are updated in `.env` files. + */ + readonly onDidEnvironmentVariablesChange: Event; + }; } + +export type RefreshOptions = { + /** + * When `true`, force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so + * it's best to only use it if user manually triggers a refresh. + */ + forceRefresh?: boolean; +}; + +/** + * Details about the environment. Note the environment folder, type and name never changes over time. + */ +export type Environment = EnvironmentPath & { + /** + * Carries details about python executable. + */ + readonly executable: { + /** + * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to + * the environment. + */ + readonly uri: Uri | undefined; + /** + * Bitness if known at this moment. + */ + readonly bitness: Bitness | undefined; + /** + * Value of `sys.prefix` in sys module if known at this moment. + */ + readonly sysPrefix: string | undefined; + }; + /** + * Carries details if it is an environment, otherwise `undefined` in case of global interpreters and others. + */ + readonly environment: + | { + /** + * Type of the environment. + */ + readonly type: EnvironmentType; + /** + * Name to the environment if any. + */ + readonly name: string | undefined; + /** + * Uri of the environment folder. + */ + readonly folderUri: Uri; + /** + * Any specific workspace folder this environment is created for. + */ + readonly workspaceFolder: WorkspaceFolder | undefined; + } + | undefined; + /** + * Carries Python version information known at this moment, carries `undefined` for envs without python. + */ + readonly version: + | (VersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string | undefined; + }) + | undefined; + /** + * Tools/plugins which created the environment or where it came from. First value in array corresponds + * to the primary tool which manages the environment, which never changes over time. + * + * Array is empty if no tool is responsible for creating/managing the environment. Usually the case for + * global interpreters. + */ + readonly tools: readonly EnvironmentTools[]; +}; + +/** + * Derived form of {@link Environment} where certain properties can no longer be `undefined`. Meant to represent an + * {@link Environment} with complete information. + */ +export type ResolvedEnvironment = Environment & { + /** + * Carries complete details about python executable. + */ + readonly executable: { + /** + * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to + * the environment. + */ + readonly uri: Uri | undefined; + /** + * Bitness of the environment. + */ + readonly bitness: Bitness; + /** + * Value of `sys.prefix` in sys module. + */ + readonly sysPrefix: string; + }; + /** + * Carries complete Python version information, carries `undefined` for envs without python. + */ + readonly version: + | (ResolvedVersionInfo & { + /** + * Value of `sys.version` in sys module if known at this moment. + */ + readonly sysVersion: string; + }) + | undefined; +}; + +export type EnvironmentsChangeEvent = { + readonly env: Environment; + /** + * * "add": New environment is added. + * * "remove": Existing environment in the list is removed. + * * "update": New information found about existing environment. + */ + readonly type: 'add' | 'remove' | 'update'; +}; + +export type ActiveEnvironmentPathChangeEvent = EnvironmentPath & { + /** + * Workspace folder the environment changed for. + */ + readonly resource: WorkspaceFolder | undefined; +}; + +/** + * Uri of a file inside a workspace or workspace folder itself. + */ +export type Resource = Uri | WorkspaceFolder; + +export type EnvironmentPath = { + /** + * The ID of the environment. + */ + readonly id: string; + /** + * Path to environment folder or path to python executable that uniquely identifies an environment. Environments + * lacking a python executable are identified by environment folder paths, whereas other envs can be identified + * using python executable path. + */ + readonly path: string; +}; + +/** + * Tool/plugin where the environment came from. It can be {@link KnownEnvironmentTools} or custom string which + * was contributed. + */ +export type EnvironmentTools = KnownEnvironmentTools | string; +/** + * Tools or plugins the Python extension currently has built-in support for. Note this list is expected to shrink + * once tools have their own separate extensions. + */ +export type KnownEnvironmentTools = + | 'Conda' + | 'Pipenv' + | 'Poetry' + | 'VirtualEnv' + | 'Venv' + | 'VirtualEnvWrapper' + | 'Pyenv' + | 'Unknown'; + +/** + * Type of the environment. It can be {@link KnownEnvironmentTypes} or custom string which was contributed. + */ +export type EnvironmentType = KnownEnvironmentTypes | string; +/** + * Environment types the Python extension is aware of. Note this list is expected to shrink once tools have their + * own separate extensions, in which case they're expected to provide the type themselves. + */ +export type KnownEnvironmentTypes = 'VirtualEnvironment' | 'Conda' | 'Unknown'; + +/** + * Carries bitness for an environment. + */ +export type Bitness = '64-bit' | '32-bit' | 'Unknown'; + +/** + * The possible Python release levels. + */ +export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; + +/** + * Release information for a Python version. + */ +export type PythonVersionRelease = { + readonly level: PythonReleaseLevel; + readonly serial: number; +}; + +export type VersionInfo = { + readonly major: number | undefined; + readonly minor: number | undefined; + readonly micro: number | undefined; + readonly release: PythonVersionRelease | undefined; +}; + +export type ResolvedVersionInfo = { + readonly major: number; + readonly minor: number; + readonly micro: number; + readonly release: PythonVersionRelease; +}; + +/** + * A record containing readonly keys. + */ +export type EnvironmentVariables = { readonly [key: string]: string | undefined }; + +export type EnvironmentVariablesChangeEvent = { + /** + * Workspace folder the environment variables changed for. + */ + readonly resource: WorkspaceFolder | undefined; + /** + * Updated value of environment variables. + */ + readonly env: EnvironmentVariables; +}; diff --git a/extensions/positron-python/src/client/application/diagnostics/checks/pythonInterpreter.ts b/extensions/positron-python/src/client/application/diagnostics/checks/pythonInterpreter.ts index 4f5133d9dcbe..31da53e75357 100644 --- a/extensions/positron-python/src/client/application/diagnostics/checks/pythonInterpreter.ts +++ b/extensions/positron-python/src/client/application/diagnostics/checks/pythonInterpreter.ts @@ -6,7 +6,7 @@ import { inject, injectable } from 'inversify'; import { DiagnosticSeverity, l10n } from 'vscode'; import '../../../common/extensions'; import * as path from 'path'; -import { IDisposableRegistry, IInterpreterPathService, Resource } from '../../../common/types'; +import { IConfigurationService, IDisposableRegistry, IInterpreterPathService, Resource } from '../../../common/types'; import { IInterpreterService } from '../../../interpreter/contracts'; import { IServiceContainer } from '../../../ioc/types'; import { BaseDiagnostic, BaseDiagnosticsService } from '../base'; @@ -28,6 +28,13 @@ import { EventName } from '../../../telemetry/constants'; import { IExtensionSingleActivationService } from '../../../activation/types'; import { cache } from '../../../common/utils/decorators'; import { noop } from '../../../common/utils/misc'; +import { getEnvironmentVariable, getOSType, OSType } from '../../../common/utils/platform'; +import { IFileSystem } from '../../../common/platform/types'; +import { traceError } from '../../../logging'; +import { getExecutable } from '../../../common/process/internal/python'; +import { getSearchPathEnvVarNames } from '../../../common/utils/exec'; +import { IProcessServiceFactory } from '../../../common/process/types'; +import { normCasePath } from '../../../common/platform/fs-paths'; const messages = { [DiagnosticCodes.NoPythonInterpretersDiagnostic]: l10n.t( @@ -36,6 +43,15 @@ const messages = { [DiagnosticCodes.InvalidPythonInterpreterDiagnostic]: l10n.t( 'An Invalid Python interpreter is selected{0}, please try changing it to enable features such as IntelliSense, linting, and debugging. See output for more details regarding why the interpreter is invalid.', ), + [DiagnosticCodes.InvalidComspecDiagnostic]: l10n.t( + 'We detected an issue with one of your environment variables that breaks features such as IntelliSense, linting and debugging. Try setting the "ComSpec" variable to a valid Command Prompt path in your system to fix it.', + ), + [DiagnosticCodes.IncompletePathVarDiagnostic]: l10n.t( + 'We detected an issue with "Path" environment variable that breaks features such as IntelliSense, linting and debugging. Please edit it to make sure it contains the "System32" subdirectories.', + ), + [DiagnosticCodes.DefaultShellErrorDiagnostic]: l10n.t( + 'We detected an issue with your default shell that breaks features such as IntelliSense, linting and debugging. Try resetting "ComSpec" and "Path" environment variables to fix it.', + ), }; export class InvalidPythonInterpreterDiagnostic extends BaseDiagnostic { @@ -61,6 +77,17 @@ export class InvalidPythonInterpreterDiagnostic extends BaseDiagnostic { } } +type DefaultShellDiagnostics = + | DiagnosticCodes.InvalidComspecDiagnostic + | DiagnosticCodes.IncompletePathVarDiagnostic + | DiagnosticCodes.DefaultShellErrorDiagnostic; + +export class DefaultShellDiagnostic extends BaseDiagnostic { + constructor(code: DefaultShellDiagnostics, resource: Resource, scope = DiagnosticScope.Global) { + super(code, messages[code], DiagnosticSeverity.Error, scope, resource, undefined, 'always'); + } +} + export const InvalidPythonInterpreterServiceId = 'InvalidPythonInterpreterServiceId'; @injectable() @@ -73,7 +100,13 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, ) { super( - [DiagnosticCodes.NoPythonInterpretersDiagnostic, DiagnosticCodes.InvalidPythonInterpreterDiagnostic], + [ + DiagnosticCodes.NoPythonInterpretersDiagnostic, + DiagnosticCodes.InvalidPythonInterpreterDiagnostic, + DiagnosticCodes.InvalidComspecDiagnostic, + DiagnosticCodes.IncompletePathVarDiagnostic, + DiagnosticCodes.DefaultShellErrorDiagnostic, + ], serviceContainer, disposableRegistry, false, @@ -95,14 +128,17 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService ); } - // eslint-disable-next-line class-methods-use-this - public async diagnose(_resource: Resource): Promise { - return []; + public async diagnose(resource: Resource): Promise { + return this.diagnoseDefaultShell(resource); } public async _manualDiagnose(resource: Resource): Promise { const workspaceService = this.serviceContainer.get(IWorkspaceService); const interpreterService = this.serviceContainer.get(IInterpreterService); + const diagnostics = await this.diagnoseDefaultShell(resource); + if (diagnostics.length > 0) { + return diagnostics; + } const hasInterpreters = await interpreterService.hasInterpreters(); const interpreterPathService = this.serviceContainer.get(IInterpreterPathService); const isInterpreterSetToDefault = interpreterPathService.get(resource) === 'python'; @@ -140,6 +176,72 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService return false; } + private async diagnoseDefaultShell(resource: Resource): Promise { + if (getOSType() !== OSType.Windows) { + return []; + } + const interpreterService = this.serviceContainer.get(IInterpreterService); + const currentInterpreter = await interpreterService.getActiveInterpreter(resource); + if (currentInterpreter) { + return []; + } + try { + await this.shellExecPython(); + } catch (ex) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((ex as any).errno === -4058) { + // ENOENT (-4058) error is thrown by Node when the default shell is invalid. + traceError('ComSpec is likely set to an invalid value', getEnvironmentVariable('ComSpec')); + if (await this.isComspecInvalid()) { + return [new DefaultShellDiagnostic(DiagnosticCodes.InvalidComspecDiagnostic, resource)]; + } + if (this.isPathVarIncomplete()) { + traceError('PATH env var appears to be incomplete', process.env.Path, process.env.PATH); + return [new DefaultShellDiagnostic(DiagnosticCodes.IncompletePathVarDiagnostic, resource)]; + } + return [new DefaultShellDiagnostic(DiagnosticCodes.DefaultShellErrorDiagnostic, resource)]; + } + } + return []; + } + + private async isComspecInvalid() { + const comSpec = getEnvironmentVariable('ComSpec') ?? ''; + const fs = this.serviceContainer.get(IFileSystem); + return fs.fileExists(comSpec).then((exists) => !exists); + } + + // eslint-disable-next-line class-methods-use-this + private isPathVarIncomplete() { + const envVars = getSearchPathEnvVarNames(); + const systemRoot = getEnvironmentVariable('SystemRoot') ?? 'C:\\WINDOWS'; + const system32 = path.join(systemRoot, 'system32'); + for (const envVar of envVars) { + const value = getEnvironmentVariable(envVar); + if (value && normCasePath(value).includes(normCasePath(system32))) { + return false; + } + } + return true; + } + + @cache(-1, true) + // eslint-disable-next-line class-methods-use-this + private async shellExecPython() { + const configurationService = this.serviceContainer.get(IConfigurationService); + const { pythonPath } = configurationService.getSettings(); + const [args] = getExecutable(); + const argv = [pythonPath, ...args]; + // Concat these together to make a set of quoted strings + const quoted = argv.reduce( + (p, c) => (p ? `${p} ${c.toCommandArgumentForPythonExt()}` : `${c.toCommandArgumentForPythonExt()}`), + '', + ); + const processServiceFactory = this.serviceContainer.get(IProcessServiceFactory); + const service = await processServiceFactory.create(); + return service.shellExec(quoted, { timeout: 15000 }); + } + @cache(1000, true) // This is to handle throttling of multiple events. protected async onHandle(diagnostics: IDiagnostic[]): Promise { if (diagnostics.length === 0) { @@ -163,6 +265,26 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService private getCommandPrompts(diagnostic: IDiagnostic): { prompt: string; command?: IDiagnosticCommand }[] { const commandFactory = this.serviceContainer.get(IDiagnosticsCommandFactory); + if ( + diagnostic.code === DiagnosticCodes.InvalidComspecDiagnostic || + diagnostic.code === DiagnosticCodes.IncompletePathVarDiagnostic || + diagnostic.code === DiagnosticCodes.DefaultShellErrorDiagnostic + ) { + const links: Record = { + InvalidComspecDiagnostic: 'https://aka.ms/AAk3djo', + IncompletePathVarDiagnostic: 'https://aka.ms/AAk744c', + DefaultShellErrorDiagnostic: 'https://aka.ms/AAk7qix', + }; + return [ + { + prompt: Common.seeInstructions, + command: commandFactory.createCommand(diagnostic, { + type: 'launch', + options: links[diagnostic.code], + }), + }, + ]; + } const prompts = [ { prompt: Common.selectPythonInterpreter, diff --git a/extensions/positron-python/src/client/application/diagnostics/constants.ts b/extensions/positron-python/src/client/application/diagnostics/constants.ts index 9fdd6ff13723..ca2867fc4f49 100644 --- a/extensions/positron-python/src/client/application/diagnostics/constants.ts +++ b/extensions/positron-python/src/client/application/diagnostics/constants.ts @@ -12,6 +12,9 @@ export enum DiagnosticCodes { InvalidPythonPathInDebuggerLaunchDiagnostic = 'InvalidPythonPathInDebuggerLaunchDiagnostic', EnvironmentActivationInPowerShellWithBatchFilesNotSupportedDiagnostic = 'EnvironmentActivationInPowerShellWithBatchFilesNotSupportedDiagnostic', InvalidPythonInterpreterDiagnostic = 'InvalidPythonInterpreterDiagnostic', + InvalidComspecDiagnostic = 'InvalidComspecDiagnostic', + IncompletePathVarDiagnostic = 'IncompletePathVarDiagnostic', + DefaultShellErrorDiagnostic = 'DefaultShellErrorDiagnostic', LSNotSupportedDiagnostic = 'LSNotSupportedDiagnostic', PythonPathDeprecatedDiagnostic = 'PythonPathDeprecatedDiagnostic', JustMyCodeDiagnostic = 'JustMyCodeDiagnostic', diff --git a/extensions/positron-python/src/client/browser/api.ts b/extensions/positron-python/src/client/browser/api.ts new file mode 100644 index 000000000000..ac2df8d0ffed --- /dev/null +++ b/extensions/positron-python/src/client/browser/api.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { BaseLanguageClient } from 'vscode-languageclient'; +import { LanguageClient } from 'vscode-languageclient/browser'; +import { PYTHON_LANGUAGE } from '../common/constants'; +import { ApiForPylance, TelemetryReporter } from '../pylanceApi'; + +export interface IBrowserExtensionApi { + /** + * @deprecated Temporarily exposed for Pylance until we expose this API generally. Will be removed in an + * iteration or two. + */ + pylance: ApiForPylance; +} + +export function buildApi(reporter: TelemetryReporter): IBrowserExtensionApi { + const api: IBrowserExtensionApi = { + pylance: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createClient: (...args: any[]): BaseLanguageClient => + new LanguageClient(PYTHON_LANGUAGE, 'Python Language Server', args[0], args[1]), + start: (client: BaseLanguageClient): Promise => client.start(), + stop: (client: BaseLanguageClient): Promise => client.stop(), + getTelemetryReporter: () => reporter, + }, + }; + + return api; +} diff --git a/extensions/positron-python/src/client/browser/extension.ts b/extensions/positron-python/src/client/browser/extension.ts index c9ecf8ac1212..28e1912f67e4 100644 --- a/extensions/positron-python/src/client/browser/extension.ts +++ b/extensions/positron-python/src/client/browser/extension.ts @@ -10,41 +10,68 @@ import { LanguageServerType } from '../activation/types'; import { AppinsightsKey, PYLANCE_EXTENSION_ID } from '../common/constants'; import { EventName } from '../telemetry/constants'; import { createStatusItem } from './intellisenseStatus'; +import { PylanceApi } from '../activation/node/pylanceApi'; +import { buildApi, IBrowserExtensionApi } from './api'; interface BrowserConfig { distUrl: string; // URL to Pylance's dist folder. } let languageClient: LanguageClient | undefined; +let pylanceApi: PylanceApi | undefined; -export async function activate(context: vscode.ExtensionContext): Promise { - const pylanceExtension = vscode.extensions.getExtension(PYLANCE_EXTENSION_ID); +export function activate(context: vscode.ExtensionContext): Promise { + const reporter = getTelemetryReporter(); + + const activationPromise = Promise.resolve(buildApi(reporter)); + const pylanceExtension = vscode.extensions.getExtension(PYLANCE_EXTENSION_ID); if (pylanceExtension) { - await runPylance(context, pylanceExtension); - return; + // Make sure we run pylance once we activated core extension. + activationPromise.then(() => runPylance(context, pylanceExtension)); + return activationPromise; } const changeDisposable = vscode.extensions.onDidChange(async () => { - const newPylanceExtension = vscode.extensions.getExtension(PYLANCE_EXTENSION_ID); + const newPylanceExtension = vscode.extensions.getExtension(PYLANCE_EXTENSION_ID); if (newPylanceExtension) { changeDisposable.dispose(); await runPylance(context, newPylanceExtension); } }); + + return activationPromise; } export async function deactivate(): Promise { - const client = languageClient; - languageClient = undefined; + if (pylanceApi) { + const api = pylanceApi; + pylanceApi = undefined; + await api.client!.stop(); + } + + if (languageClient) { + const client = languageClient; + languageClient = undefined; - await client?.stop(); - await client?.dispose(); + await client.stop(); + await client.dispose(); + } } async function runPylance( context: vscode.ExtensionContext, - pylanceExtension: vscode.Extension, + pylanceExtension: vscode.Extension, ): Promise { + context.subscriptions.push(createStatusItem()); + + pylanceExtension = await getActivatedExtension(pylanceExtension); + const api = pylanceExtension.exports; + if (api.client && api.client.isEnabled()) { + pylanceApi = api; + await api.client.start(); + return; + } + const { extensionUri, packageJSON } = pylanceExtension; const distUrl = vscode.Uri.joinPath(extensionUri, 'dist'); @@ -111,8 +138,6 @@ async function runPylance( ); await client.start(); - - context.subscriptions.push(createStatusItem()); } catch (e) { console.log(e); } @@ -196,3 +221,11 @@ function sendTelemetryEventBrowser( reporter.sendTelemetryEvent(eventNameSent, customProperties, measures); } } + +async function getActivatedExtension(extension: vscode.Extension): Promise> { + if (!extension.isActive) { + await extension.activate(); + } + + return extension; +} diff --git a/extensions/positron-python/src/client/common/application/applicationEnvironment.ts b/extensions/positron-python/src/client/common/application/applicationEnvironment.ts index e3d78477996d..4b66893d6c0b 100644 --- a/extensions/positron-python/src/client/common/application/applicationEnvironment.ts +++ b/extensions/positron-python/src/client/common/application/applicationEnvironment.ts @@ -7,6 +7,7 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; import { parse } from 'semver'; import * as vscode from 'vscode'; +import { traceError } from '../../logging'; import { Channel } from '../constants'; import { IPlatformService } from '../platform/types'; import { ICurrentProcess, IPathUtils } from '../types'; @@ -70,19 +71,22 @@ export class ApplicationEnvironment implements IApplicationEnvironment { public get extensionName(): string { return this.packageJson.displayName; } - /** - * At the time of writing this API, the vscode.env.shell isn't officially released in stable version of VS Code. - * Using this in stable version seems to throw errors in VSC with messages being displayed to the user about use of - * unstable API. - * Solution - log and suppress the errors. - * @readonly - * @type {(string)} - * @memberof ApplicationEnvironment - */ + public get shell(): string { return vscode.env.shell; } + public get onDidChangeShell(): vscode.Event { + try { + return vscode.env.onDidChangeShell; + } catch (ex) { + traceError('Failed to get onDidChangeShell API', ex); + // `onDidChangeShell` is a proposed API at the time of writing this, so wrap this in a try...catch + // block in case the API is removed or changed. + return new vscode.EventEmitter().event; + } + } + public get packageJson(): any { return require('../../../../package.json'); } diff --git a/extensions/positron-python/src/client/common/application/commands.ts b/extensions/positron-python/src/client/common/application/commands.ts index 18d035cde6c9..277baffd19a4 100644 --- a/extensions/positron-python/src/client/common/application/commands.ts +++ b/extensions/positron-python/src/client/common/application/commands.ts @@ -62,7 +62,7 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['workbench.action.quickOpen']: [string]; ['workbench.action.openWalkthrough']: [string | { category: string; step: string }, boolean | undefined]; ['workbench.extensions.installExtension']: [ - Uri | 'ms-python.python', + Uri | string, ( | { installOnlyNewlyAddedFromExtensionPackVSIX?: boolean; diff --git a/extensions/positron-python/src/client/common/application/types.ts b/extensions/positron-python/src/client/common/application/types.ts index 69caf30a261b..1b054eda687c 100644 --- a/extensions/positron-python/src/client/common/application/types.ts +++ b/extensions/positron-python/src/client/common/application/types.ts @@ -1048,6 +1048,10 @@ export interface IApplicationEnvironment { * @memberof IApplicationShell */ readonly shell: string; + /** + * An {@link Event} which fires when the default shell changes. + */ + readonly onDidChangeShell: Event; /** * Gets the vscode channel (whether 'insiders' or 'stable'). */ diff --git a/extensions/positron-python/src/client/common/experiments/groups.ts b/extensions/positron-python/src/client/common/experiments/groups.ts index 7d9c27bf33e9..5884aafd122d 100644 --- a/extensions/positron-python/src/client/common/experiments/groups.ts +++ b/extensions/positron-python/src/client/common/experiments/groups.ts @@ -6,3 +6,11 @@ export enum ShowExtensionSurveyPrompt { export enum ShowToolsExtensionPrompt { experiment = 'pythonPromptNewToolsExt', } + +export enum TerminalEnvVarActivation { + experiment = 'pythonTerminalEnvVarActivation', +} + +export enum ShowFormatterExtensionPrompt { + experiment = 'pythonPromptNewFormatterExt', +} diff --git a/extensions/positron-python/src/client/common/experiments/helpers.ts b/extensions/positron-python/src/client/common/experiments/helpers.ts new file mode 100644 index 000000000000..4aed04da3fd0 --- /dev/null +++ b/extensions/positron-python/src/client/common/experiments/helpers.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { workspace } from 'vscode'; +import { isTestExecution } from '../constants'; +import { IExperimentService } from '../types'; +import { TerminalEnvVarActivation } from './groups'; + +export function inTerminalEnvVarExperiment(experimentService: IExperimentService): boolean { + if (workspace.workspaceFile && !isTestExecution()) { + // Don't run experiment in multi-root workspaces for now, requires work on VSCode: + // https://github.com/microsoft/vscode/issues/171173 + return false; + } + if (!experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)) { + return false; + } + return true; +} diff --git a/extensions/positron-python/src/client/common/net/fileDownloader.ts b/extensions/positron-python/src/client/common/net/fileDownloader.ts deleted file mode 100644 index 6ddd06bcc940..000000000000 --- a/extensions/positron-python/src/client/common/net/fileDownloader.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { inject, injectable } from 'inversify'; -import * as requestTypes from 'request'; -import { l10n, Progress } from 'vscode'; -import { traceLog } from '../../logging'; -import { IApplicationShell } from '../application/types'; -import { Octicons } from '../constants'; -import { IFileSystem, WriteStream } from '../platform/types'; -import { DownloadOptions, IFileDownloader, IHttpClient } from '../types'; -import { noop } from '../utils/misc'; - -@injectable() -export class FileDownloader implements IFileDownloader { - constructor( - @inject(IHttpClient) private readonly httpClient: IHttpClient, - @inject(IFileSystem) private readonly fs: IFileSystem, - @inject(IApplicationShell) private readonly appShell: IApplicationShell, - ) {} - public async downloadFile(uri: string, options: DownloadOptions): Promise { - traceLog(l10n.t('Downloading {0}...', uri)); - const tempFile = await this.fs.createTemporaryFile(options.extension); - - await this.downloadFileWithStatusBarProgress(uri, options.progressMessagePrefix, tempFile.filePath).then( - noop, - (ex) => { - tempFile.dispose(); - return Promise.reject(ex); - }, - ); - - return tempFile.filePath; - } - public async downloadFileWithStatusBarProgress( - uri: string, - progressMessage: string, - tmpFilePath: string, - ): Promise { - await this.appShell.withProgressCustomIcon(Octicons.Downloading, async (progress) => { - const req = await this.httpClient.downloadFile(uri); - const fileStream = this.fs.createWriteStream(tmpFilePath); - return this.displayDownloadProgress(uri, progress, req, fileStream, progressMessage); - }); - } - - public async displayDownloadProgress( - uri: string, - progress: Progress<{ message?: string; increment?: number }>, - request: requestTypes.Request, - fileStream: WriteStream, - progressMessagePrefix: string, - ): Promise { - return new Promise((resolve, reject) => { - request.on('response', (response) => { - if (response.statusCode !== 200) { - reject( - new Error(`Failed with status ${response.statusCode}, ${response.statusMessage}, Uri ${uri}`), - ); - } - }); - - const requestProgress = require('request-progress'); - requestProgress(request) - .on('progress', (state: RequestProgressState) => { - const message = formatProgressMessageWithState(progressMessagePrefix, state); - progress.report({ message }); - }) - // Handle errors from download. - .on('error', reject) - .pipe(fileStream) - // Handle error in writing to fs. - .on('error', reject) - .on('close', resolve); - }); - } -} - -type RequestProgressState = { - percent: number; - speed: number; - size: { - total: number; - transferred: number; - }; - time: { - elapsed: number; - remaining: number; - }; -}; - -function formatProgressMessageWithState(progressMessagePrefix: string, state: RequestProgressState): string { - const received = Math.round(state.size.transferred / 1024); - const total = Math.round(state.size.total / 1024); - const percentage = Math.round(100 * state.percent); - - return l10n.t( - '{0}{1} of {2} KB ({3}%)', - progressMessagePrefix, - received.toString(), - total.toString(), - percentage.toString(), - ); -} diff --git a/extensions/positron-python/src/client/common/net/httpClient.ts b/extensions/positron-python/src/client/common/net/httpClient.ts deleted file mode 100644 index 8aac63d17142..000000000000 --- a/extensions/positron-python/src/client/common/net/httpClient.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { inject, injectable } from 'inversify'; -import { parse, ParseError } from 'jsonc-parser'; -import type * as requestTypes from 'request'; -import { IHttpClient } from '../../common/types'; -import { IServiceContainer } from '../../ioc/types'; -import { traceError } from '../../logging'; -import { IWorkspaceService } from '../application/types'; - -@injectable() -export class HttpClient implements IHttpClient { - public readonly requestOptions: requestTypes.CoreOptions; - constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { - const workspaceService = serviceContainer.get(IWorkspaceService); - this.requestOptions = { proxy: workspaceService.getConfiguration('http').get('proxy', '') }; - } - - public async downloadFile(uri: string): Promise { - const request = ((await import('request')) as any) as typeof requestTypes; - return request(uri, this.requestOptions); - } - - public async getJSON(uri: string, strict: boolean = true): Promise { - const body = await this.getContents(uri); - return this.parseBodyToJSON(body, strict); - } - - public async parseBodyToJSON(body: string, strict: boolean): Promise { - if (strict) { - return JSON.parse(body); - } else { - let errors: ParseError[] = []; - const content = parse(body, errors, { allowTrailingComma: true, disallowComments: false }) as T; - if (errors.length > 0) { - traceError('JSONC parser returned ParseError codes', errors); - } - return content; - } - } - - public async exists(uri: string): Promise { - const request = require('request') as typeof requestTypes; - return new Promise((resolve) => { - try { - request - .get(uri, this.requestOptions) - .on('response', (response) => resolve(response.statusCode === 200)) - .on('error', () => resolve(false)); - } catch { - resolve(false); - } - }); - } - private async getContents(uri: string): Promise { - const request = require('request') as typeof requestTypes; - return new Promise((resolve, reject) => { - request(uri, this.requestOptions, (ex, response, body) => { - if (ex) { - return reject(ex); - } - if (response.statusCode !== 200) { - return reject( - new Error(`Failed with status ${response.statusCode}, ${response.statusMessage}, Uri ${uri}`), - ); - } - resolve(body); - }); - }); - } -} diff --git a/extensions/positron-python/src/client/common/process/logger.ts b/extensions/positron-python/src/client/common/process/logger.ts index ebb1ad019a48..5c8f04cbec30 100644 --- a/extensions/positron-python/src/client/common/process/logger.ts +++ b/extensions/positron-python/src/client/common/process/logger.ts @@ -7,11 +7,11 @@ import { inject, injectable } from 'inversify'; import { traceLog } from '../../logging'; import { IWorkspaceService } from '../application/types'; import { isCI, isTestExecution } from '../constants'; -import { Logging } from '../utils/localize'; import { getOSType, getUserHomeDir, OSType } from '../utils/platform'; import { IProcessLogger, SpawnOptions } from './types'; import { escapeRegExp } from 'lodash'; import { replaceAll } from '../stringUtils'; +import { identifyShellFromShellPath } from '../terminal/shellDetectors/baseShellDetector'; @injectable() export class ProcessLogger implements IProcessLogger { @@ -27,8 +27,11 @@ export class ProcessLogger implements IProcessLogger { ? [fileOrCommand, ...args].map((e) => e.trimQuotes().toCommandArgumentForPythonExt()).join(' ') : fileOrCommand; const info = [`> ${this.getDisplayCommands(command)}`]; - if (options && options.cwd) { - info.push(`${Logging.currentWorkingDirectory} ${this.getDisplayCommands(options.cwd)}`); + if (options?.cwd) { + info.push(`cwd: ${this.getDisplayCommands(options.cwd)}`); + } + if (typeof options?.shell === 'string') { + info.push(`shell: ${identifyShellFromShellPath(options?.shell)}`); } info.forEach((line) => { diff --git a/extensions/positron-python/src/client/common/process/rawProcessApis.ts b/extensions/positron-python/src/client/common/process/rawProcessApis.ts index fb5108322751..59b5fe69c9cd 100644 --- a/extensions/positron-python/src/client/common/process/rawProcessApis.ts +++ b/extensions/positron-python/src/client/common/process/rawProcessApis.ts @@ -13,6 +13,8 @@ import { noop } from '../utils/misc'; import { decodeBuffer } from './decoder'; import { traceVerbose } from '../../logging'; +const PS_ERROR_SCREEN_BOGUS = /your [0-9]+x[0-9]+ screen size is bogus\. expect trouble/; + function getDefaultOptions(options: T, defaultEnv?: EnvironmentVariables): T { const defaultOptions = { ...options }; const execOptions = defaultOptions as SpawnOptions; @@ -136,7 +138,13 @@ export function plainExec( } const stderr: string | undefined = stderrBuffers.length === 0 ? undefined : decodeBuffer(stderrBuffers, encoding); - if (stderr && stderr.length > 0 && options.throwOnStdErr) { + if ( + stderr && + stderr.length > 0 && + options.throwOnStdErr && + // ignore this specific error silently; see this issue for context: https://github.com/microsoft/vscode/issues/75932 + !(PS_ERROR_SCREEN_BOGUS.test(stderr) && stderr.replace(PS_ERROR_SCREEN_BOGUS, '').trim().length === 0) + ) { deferred.reject(new StdErrError(stderr)); } else { let stdout = decodeBuffer(stdoutBuffers, encoding); diff --git a/extensions/positron-python/src/client/common/serviceRegistry.ts b/extensions/positron-python/src/client/common/serviceRegistry.ts index b68f56042c1f..93f069f18288 100644 --- a/extensions/positron-python/src/client/common/serviceRegistry.ts +++ b/extensions/positron-python/src/client/common/serviceRegistry.ts @@ -8,8 +8,6 @@ import { IEditorUtils, IExperimentService, IExtensions, - IFileDownloader, - IHttpClient, IInstaller, IInterpreterPathService, IPathUtils, @@ -58,8 +56,6 @@ import { ExperimentService } from './experiments/service'; import { ProductInstaller } from './installer/productInstaller'; import { InterpreterPathService } from './interpreterPathService'; import { BrowserService } from './net/browser'; -import { FileDownloader } from './net/fileDownloader'; -import { HttpClient } from './net/httpClient'; import { PersistentStateFactory } from './persistentState'; import { IS_WINDOWS } from './platform/constants'; import { PathUtils } from './platform/pathUtils'; @@ -128,8 +124,6 @@ export function registerTypes(serviceManager: IServiceManager): void { serviceManager.addSingleton(IApplicationEnvironment, ApplicationEnvironment); serviceManager.addSingleton(ILanguageService, LanguageService); serviceManager.addSingleton(IBrowserService, BrowserService); - serviceManager.addSingleton(IHttpClient, HttpClient); - serviceManager.addSingleton(IFileDownloader, FileDownloader); serviceManager.addSingleton(IEditorUtils, EditorUtils); serviceManager.addSingleton(ITerminalActivator, TerminalActivator); serviceManager.addSingleton( diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/condaActivationProvider.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/condaActivationProvider.ts index 09dfc8ce5c92..d209550e04a4 100644 --- a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/condaActivationProvider.ts +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/condaActivationProvider.ts @@ -14,11 +14,6 @@ import { IPlatformService } from '../../platform/types'; import { IConfigurationService } from '../../types'; import { ITerminalActivationCommandProvider, TerminalShellType } from '../types'; -// Version number of conda that requires we call activate with 'conda activate' instead of just 'activate' -const CondaRequiredMajor = 4; -const CondaRequiredMinor = 4; -const CondaRequiredMinorForPowerShell = 6; - /** * Support conda env activation (in the terminal). */ @@ -65,57 +60,38 @@ export class CondaActivationCommandProvider implements ITerminalActivationComman const condaEnv = envInfo.name.length > 0 ? envInfo.name : envInfo.path; - // Algorithm differs based on version - // Old version, just call activate directly. - // New version, call activate from the same path as our python path, then call it again to activate our environment. - // -- note that the 'default' conda location won't allow activate to work for the environment sometimes. - const versionInfo = await this.condaService.getCondaVersion(); - if (versionInfo && versionInfo.major >= CondaRequiredMajor) { - // Conda added support for powershell in 4.6. + // New version. + const interpreterPath = await this.condaService.getInterpreterPathForEnvironment(envInfo); + const activatePath = await this.condaService.getActivationScriptFromInterpreter(interpreterPath, envInfo.name); + // eslint-disable-next-line camelcase + if (activatePath?.path) { if ( - versionInfo.minor >= CondaRequiredMinorForPowerShell && - (targetShell === TerminalShellType.powershell || targetShell === TerminalShellType.powershellCore) + this.platform.isWindows && + targetShell !== TerminalShellType.bash && + targetShell !== TerminalShellType.gitbash ) { - return _getPowershellCommands(condaEnv); + return [activatePath.path, `conda activate ${condaEnv.toCommandArgumentForPythonExt()}`]; } - if (versionInfo.minor >= CondaRequiredMinor) { - // New version. - const interpreterPath = await this.condaService.getInterpreterPathForEnvironment(envInfo); - const activatePath = await this.condaService.getActivationScriptFromInterpreter( - interpreterPath, - envInfo.name, - ); + + const condaInfo = await this.condaService.getCondaInfo(); + + if ( + activatePath.type !== 'global' || // eslint-disable-next-line camelcase - if (activatePath?.path) { - if ( - this.platform.isWindows && - targetShell !== TerminalShellType.bash && - targetShell !== TerminalShellType.gitbash - ) { - return [activatePath.path, `conda activate ${condaEnv.toCommandArgumentForPythonExt()}`]; - } - - const condaInfo = await this.condaService.getCondaInfo(); - - if ( - activatePath.type !== 'global' || - // eslint-disable-next-line camelcase - condaInfo?.conda_shlvl === undefined || - condaInfo.conda_shlvl === -1 - ) { - // activatePath is not the global activate path, or we don't have a shlvl, or it's -1(conda never sourced). - // and we need to source the activate path. - if (activatePath.path === 'activate') { - return [ - `source ${activatePath.path}`, - `conda activate ${condaEnv.toCommandArgumentForPythonExt()}`, - ]; - } - return [`source ${activatePath.path} ${condaEnv.toCommandArgumentForPythonExt()}`]; - } - return [`conda activate ${condaEnv.toCommandArgumentForPythonExt()}`]; + condaInfo?.conda_shlvl === undefined || + condaInfo.conda_shlvl === -1 + ) { + // activatePath is not the global activate path, or we don't have a shlvl, or it's -1(conda never sourced). + // and we need to source the activate path. + if (activatePath.path === 'activate') { + return [ + `source ${activatePath.path}`, + `conda activate ${condaEnv.toCommandArgumentForPythonExt()}`, + ]; } + return [`source ${activatePath.path} ${condaEnv.toCommandArgumentForPythonExt()}`]; } + return [`conda activate ${condaEnv.toCommandArgumentForPythonExt()}`]; } switch (targetShell) { diff --git a/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts b/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts index 8d9357069023..74d1fa5c6a2f 100644 --- a/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts +++ b/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts @@ -57,22 +57,26 @@ export abstract class BaseShellDetector implements IShellDetector { terminal?: Terminal, ): TerminalShellType | undefined; public identifyShellFromShellPath(shellPath: string): TerminalShellType { - // Remove .exe extension so shells can be more consistently detected - // on Windows (including Cygwin). - const basePath = shellPath.replace(/\.exe$/, ''); + return identifyShellFromShellPath(shellPath); + } +} - const shell = Array.from(detectableShells.keys()).reduce((matchedShell, shellToDetect) => { - if (matchedShell === TerminalShellType.other) { - const pat = detectableShells.get(shellToDetect); - if (pat && pat.test(basePath)) { - return shellToDetect; - } +export function identifyShellFromShellPath(shellPath: string): TerminalShellType { + // Remove .exe extension so shells can be more consistently detected + // on Windows (including Cygwin). + const basePath = shellPath.replace(/\.exe$/, ''); + + const shell = Array.from(detectableShells.keys()).reduce((matchedShell, shellToDetect) => { + if (matchedShell === TerminalShellType.other) { + const pat = detectableShells.get(shellToDetect); + if (pat && pat.test(basePath)) { + return shellToDetect; } - return matchedShell; - }, TerminalShellType.other); + } + return matchedShell; + }, TerminalShellType.other); - traceVerbose(`Shell path '${shellPath}', base path '${basePath}'`); - traceVerbose(`Shell path identified as shell '${shell}'`); - return shell; - } + traceVerbose(`Shell path '${shellPath}', base path '${basePath}'`); + traceVerbose(`Shell path identified as shell '${shell}'`); + return shell; } diff --git a/extensions/positron-python/src/client/common/types.ts b/extensions/positron-python/src/client/common/types.ts index 8f340c3e01e2..3fac5e7e0044 100644 --- a/extensions/positron-python/src/client/common/types.ts +++ b/extensions/positron-python/src/client/common/types.ts @@ -4,7 +4,6 @@ 'use strict'; import { Socket } from 'net'; -import { Request as RequestResult } from 'request'; import { CancellationToken, ConfigurationChangeEvent, @@ -360,41 +359,6 @@ export type DownloadOptions = { extension: 'tmp' | string; }; -export const IFileDownloader = Symbol('IFileDownloader'); -/** - * File downloader, that'll display progress in the status bar. - * - * @export - * @interface IFileDownloader - */ -export interface IFileDownloader { - /** - * Download file and display progress in statusbar. - * Optionnally display progress in the provided output channel. - * - * @param {string} uri - * @param {DownloadOptions} options - * @returns {Promise} - * @memberof IFileDownloader - */ - downloadFile(uri: string, options: DownloadOptions): Promise; -} - -export const IHttpClient = Symbol('IHttpClient'); -export interface IHttpClient { - downloadFile(uri: string): Promise; - /** - * Downloads file from uri as string and parses them into JSON objects - * @param uri The uri to download the JSON from - * @param strict Set `false` to allow trailing comma and comments in the JSON, defaults to `true` - */ - getJSON(uri: string, strict?: boolean): Promise; - /** - * Returns the url is valid (i.e. return status code of 200). - */ - exists(uri: string): Promise; -} - export const IExtensionContext = Symbol('ExtensionContext'); export interface IExtensionContext extends ExtensionContext {} diff --git a/extensions/positron-python/src/client/common/utils/localize.ts b/extensions/positron-python/src/client/common/utils/localize.ts index 60cbfd295f88..509287d87533 100644 --- a/extensions/positron-python/src/client/common/utils/localize.ts +++ b/extensions/positron-python/src/client/common/utils/localize.ts @@ -49,6 +49,7 @@ export namespace Diagnostics { export namespace Common { export const allow = l10n.t('Allow'); + export const seeInstructions = l10n.t('See Instructions'); export const close = l10n.t('Close'); export const bannerLabelYes = l10n.t('Yes'); export const bannerLabelNo = l10n.t('No'); @@ -193,6 +194,7 @@ export namespace Interpreters { export const condaInheritEnvMessage = l10n.t( 'We noticed you\'re using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we recommend that you let the Python extension change "terminal.integrated.inheritEnv" to false in your user settings. [Learn more](https://aka.ms/AA66i8f).', ); + export const activatingTerminals = l10n.t('Reactivating terminals...'); export const activatedCondaEnvLaunch = l10n.t( 'We noticed VS Code was launched from an activated conda environment, would you like to select it?', ); @@ -204,8 +206,12 @@ export namespace Interpreters { export const selectInterpreterTip = l10n.t( 'Tip: you can change the Python interpreter used by the Python extension by clicking on the Python version in the status bar', ); - export const installPythonTerminalMessage = l10n.t( - '💡 Please try installing the python package using your package manager. Alternatively you can also download it from https://www.python.org/downloads', + export const installPythonTerminalMessageLinux = l10n.t( + '💡 Please try installing the Python package using your package manager. Alternatively you can also download it from https://www.python.org/downloads', + ); + + export const installPythonTerminalMacMessage = l10n.t( + '💡 Brew does not seem to be available. Please try to download Python from https://www.python.org/downloads. Alternatively, you can install the Python package using some other available package manager.', ); export const changePythonInterpreter = l10n.t('Change Python Interpreter'); export const selectedPythonInterpreter = l10n.t('Selected Python Interpreter'); @@ -239,10 +245,6 @@ export namespace OutputChannelNames { export const pythonTest = l10n.t('Python Test Log'); } -export namespace Logging { - export const currentWorkingDirectory = l10n.t('cwd:'); -} - export namespace Linters { export const selectLinter = l10n.t('Select Linter'); } @@ -437,9 +439,10 @@ export namespace CreateEnv { export namespace Venv { export const creating = l10n.t('Creating venv...'); export const created = l10n.t('Environment created...'); + export const upgradingPip = l10n.t('Upgrading pip...'); export const installingPackages = l10n.t('Installing packages...'); export const errorCreatingEnvironment = l10n.t('Error while creating virtual environment.'); - export const selectPythonQuickPickTitle = l10n.t('Select a python to use for environment creation'); + export const selectPythonPlaceHolder = l10n.t('Select a Python installation to create the virtual environment'); 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'); @@ -472,4 +475,24 @@ export namespace ToolsExtensions { export const installPylintExtension = l10n.t('Install Pylint extension'); export const installFlake8Extension = l10n.t('Install Flake8 extension'); export const installISortExtension = l10n.t('Install isort extension'); + + export const selectBlackFormatterPrompt = l10n.t( + 'You have the Black formatter extension installed, would you like to use that as the default formatter?', + ); + + export const selectAutopep8FormatterPrompt = l10n.t( + 'You have the Autopep8 formatter extension installed, would you like to use that as the default formatter?', + ); + + export const selectMultipleFormattersPrompt = l10n.t( + 'You have multiple formatters installed, would you like to select one as the default formatter?', + ); + + export const installBlackFormatterPrompt = l10n.t( + 'You triggered formatting with Black, would you like to install one of our new formatter extensions? This will also set it as the default formatter for Python.', + ); + + export const installAutopep8FormatterPrompt = l10n.t( + 'You triggered formatting with Autopep8, would you like to install one of our new formatter extension? This will also set it as the default formatter for Python.', + ); } diff --git a/extensions/positron-python/src/client/common/vscodeApis/extensionsApi.ts b/extensions/positron-python/src/client/common/vscodeApis/extensionsApi.ts index 27e0657f0687..ece424847a16 100644 --- a/extensions/positron-python/src/client/common/vscodeApis/extensionsApi.ts +++ b/extensions/positron-python/src/client/common/vscodeApis/extensionsApi.ts @@ -3,15 +3,15 @@ import * as path from 'path'; import * as fs from 'fs-extra'; -import { Extension, extensions } from 'vscode'; +import * as vscode from 'vscode'; import { PVSC_EXTENSION_ID } from '../constants'; -export function getExtension(extensionId: string): Extension | undefined { - return extensions.getExtension(extensionId); +export function getExtension(extensionId: string): vscode.Extension | undefined { + return vscode.extensions.getExtension(extensionId); } export function isExtensionEnabled(extensionId: string): boolean { - return extensions.getExtension(extensionId) !== undefined; + return vscode.extensions.getExtension(extensionId) !== undefined; } export function isExtensionDisabled(extensionId: string): boolean { @@ -28,3 +28,7 @@ export function isExtensionDisabled(extensionId: string): boolean { } return false; } + +export function isInsider(): boolean { + return vscode.env.appName.includes('Insider'); +} diff --git a/extensions/positron-python/src/client/common/vscodeApis/windowApis.ts b/extensions/positron-python/src/client/common/vscodeApis/windowApis.ts index c33c14ca9875..5c279b890a9f 100644 --- a/extensions/positron-python/src/client/common/vscodeApis/windowApis.ts +++ b/extensions/positron-python/src/client/common/vscodeApis/windowApis.ts @@ -112,7 +112,12 @@ export async function showQuickPickWithBack( }), quickPick.onDidAccept(() => { if (!deferred.completed) { - deferred.resolve(quickPick.selectedItems.map((item) => item)); + if (quickPick.canSelectMany) { + deferred.resolve(quickPick.selectedItems.map((item) => item)); + } else { + deferred.resolve(quickPick.selectedItems[0]); + } + quickPick.hide(); } }), diff --git a/extensions/positron-python/src/client/common/vscodeApis/workspaceApis.ts b/extensions/positron-python/src/client/common/vscodeApis/workspaceApis.ts index fda05e2477af..74200ba46924 100644 --- a/extensions/positron-python/src/client/common/vscodeApis/workspaceApis.ts +++ b/extensions/positron-python/src/client/common/vscodeApis/workspaceApis.ts @@ -1,43 +1,57 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - CancellationToken, - ConfigurationScope, - GlobPattern, - Uri, - workspace, - WorkspaceConfiguration, - WorkspaceEdit, - WorkspaceFolder, -} from 'vscode'; +import * as vscode from 'vscode'; import { Resource } from '../types'; -export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { - return workspace.workspaceFolders; +export function getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] | undefined { + return vscode.workspace.workspaceFolders; } -export function getWorkspaceFolder(uri: Resource): WorkspaceFolder | undefined { - return uri ? workspace.getWorkspaceFolder(uri) : undefined; +export function getWorkspaceFolder(uri: Resource): vscode.WorkspaceFolder | undefined { + return uri ? vscode.workspace.getWorkspaceFolder(uri) : undefined; } export function getWorkspaceFolderPaths(): string[] { - return workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? []; + return vscode.workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? []; } -export function getConfiguration(section?: string, scope?: ConfigurationScope | null): WorkspaceConfiguration { - return workspace.getConfiguration(section, scope); +export function getConfiguration( + section?: string, + scope?: vscode.ConfigurationScope | null, +): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(section, scope); } -export function applyEdit(edit: WorkspaceEdit): Thenable { - return workspace.applyEdit(edit); +export function applyEdit(edit: vscode.WorkspaceEdit): Thenable { + return vscode.workspace.applyEdit(edit); } export function findFiles( - include: GlobPattern, - exclude?: GlobPattern | null, + include: vscode.GlobPattern, + exclude?: vscode.GlobPattern | null, maxResults?: number, - token?: CancellationToken, -): Thenable { - return workspace.findFiles(include, exclude, maxResults, token); + token?: vscode.CancellationToken, +): Thenable { + return vscode.workspace.findFiles(include, exclude, maxResults, token); +} + +export function onDidSaveTextDocument( + listener: (e: vscode.TextDocument) => unknown, + thisArgs?: unknown, + disposables?: vscode.Disposable[], +): vscode.Disposable { + return vscode.workspace.onDidSaveTextDocument(listener, thisArgs, disposables); +} + +export function getOpenTextDocuments(): readonly vscode.TextDocument[] { + return vscode.workspace.textDocuments; +} + +export function onDidOpenTextDocument(handler: (doc: vscode.TextDocument) => void): vscode.Disposable { + return vscode.workspace.onDidOpenTextDocument(handler); +} + +export function onDidChangeTextDocument(handler: (e: vscode.TextDocumentChangeEvent) => void): vscode.Disposable { + return vscode.workspace.onDidChangeTextDocument(handler); } diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/attach.ts b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/attach.ts index 635dc88dbac4..bdc72680d861 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/attach.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/attach.ts @@ -26,6 +26,9 @@ export class AttachConfigurationResolver extends BaseConfigurationResolver dbgConfig.debugOptions!.indexOf(item) === pos, ); } + if (debugConfiguration.clientOS === undefined) { + debugConfiguration.clientOS = getOSType() === OSType.Windows ? 'windows' : 'unix'; + } return debugConfiguration; } @@ -77,10 +80,8 @@ export class AttachConfigurationResolver extends BaseConfigurationResolver debugConfiguration: DebugConfiguration, _token?: CancellationToken, ): Promise { + if (debugConfiguration.clientOS === undefined) { + debugConfiguration.clientOS = getOSType() === OSType.Windows ? 'windows' : 'unix'; + } return debugConfiguration as T; } @@ -120,16 +123,39 @@ export abstract class BaseConfigurationResolver undefined, ); } - if (debugConfiguration.python === '${command:python.interpreterPath}' || !debugConfiguration.python) { + + if (debugConfiguration.python === '${command:python.interpreterPath}') { this.pythonPathSource = PythonPathSource.settingsJson; + const interpreterPath = + (await this.interpreterService.getActiveInterpreter(workspaceFolder))?.path ?? + this.configurationService.getSettings(workspaceFolder).pythonPath; + debugConfiguration.python = interpreterPath; + } else if (debugConfiguration.python === undefined) { + this.pythonPathSource = PythonPathSource.settingsJson; + debugConfiguration.python = debugConfiguration.pythonPath; } else { this.pythonPathSource = PythonPathSource.launchJson; + debugConfiguration.python = resolveVariables( + debugConfiguration.python ?? debugConfiguration.pythonPath, + workspaceFolder?.fsPath, + undefined, + ); + } + + if ( + debugConfiguration.debugAdapterPython === '${command:python.interpreterPath}' || + debugConfiguration.debugAdapterPython === undefined + ) { + debugConfiguration.debugAdapterPython = debugConfiguration.pythonPath ?? debugConfiguration.python; + } + if ( + debugConfiguration.debugLauncherPython === '${command:python.interpreterPath}' || + debugConfiguration.debugLauncherPython === undefined + ) { + debugConfiguration.debugLauncherPython = debugConfiguration.pythonPath ?? debugConfiguration.python; } - debugConfiguration.python = resolveVariables( - debugConfiguration.python ? debugConfiguration.python : undefined, - workspaceFolder?.fsPath, - undefined, - ); + + delete debugConfiguration.pythonPath; } protected static debugOption(debugOptions: DebugOptions[], debugOption: DebugOptions): void { diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts index 781b25a26510..0ccaa9964054 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts @@ -13,7 +13,10 @@ import { getSearchPathEnvVarNames } from '../../../../common/utils/exec'; export const IDebugEnvironmentVariablesService = Symbol('IDebugEnvironmentVariablesService'); export interface IDebugEnvironmentVariablesService { - getEnvironmentVariables(args: LaunchRequestArguments): Promise; + getEnvironmentVariables( + args: LaunchRequestArguments, + baseVars?: EnvironmentVariables, + ): Promise; } @injectable() @@ -23,7 +26,10 @@ export class DebugEnvironmentVariablesHelper implements IDebugEnvironmentVariabl @inject(ICurrentProcess) private process: ICurrentProcess, ) {} - public async getEnvironmentVariables(args: LaunchRequestArguments): Promise { + public async getEnvironmentVariables( + args: LaunchRequestArguments, + baseVars?: EnvironmentVariables, + ): Promise { const pathVariableName = getSearchPathEnvVarNames()[0]; // Merge variables from both .env file and env json variables. @@ -37,6 +43,9 @@ export class DebugEnvironmentVariablesHelper implements IDebugEnvironmentVariabl // "overwrite: true" to ensure that debug-configuration env variable values // take precedence over env file. this.envParser.mergeVariables(debugLaunchEnvVars, env, { overwrite: true }); + if (baseVars) { + this.envParser.mergeVariables(baseVars, env); + } // Append the PYTHONPATH and PATH variables. this.envParser.appendPath(env, debugLaunchEnvVars[pathVariableName]); diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts index d5cb419e031d..6a28075d4353 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts @@ -9,6 +9,8 @@ import { InvalidPythonPathInDebuggerServiceId } from '../../../../application/di import { IDiagnosticsService, IInvalidPythonPathInDebuggerService } from '../../../../application/diagnostics/types'; import { IConfigurationService } from '../../../../common/types'; import { getOSType, OSType } from '../../../../common/utils/platform'; +import { EnvironmentVariables } from '../../../../common/variables/types'; +import { IEnvironmentActivationService } from '../../../../interpreter/activation/types'; import { IInterpreterService } from '../../../../interpreter/contracts'; import { DebuggerTypeName } from '../../../constants'; import { DebugOptions, DebugPurpose, LaunchRequestArguments } from '../../../types'; @@ -24,6 +26,7 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver { + const isPythonSet = debugConfiguration.python !== undefined; if (debugConfiguration.python === undefined) { debugConfiguration.python = debugConfiguration.pythonPath; } @@ -96,10 +103,17 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver(); +export function reportActiveInterpreterChanged(e: ActiveEnvironmentChangeEvent): void { + onDidActiveInterpreterChangedEvent.fire({ id: getEnvID(e.path), path: e.path, resource: e.resource }); + reportActiveInterpreterChangedDeprecated({ path: e.path, resource: e.resource?.uri }); +} + +const onEnvironmentsChanged = new EventEmitter(); +const onEnvironmentVariablesChanged = new EventEmitter(); +const environmentsReference = new Map(); + +/** + * Make all properties in T mutable. + */ +type Mutable = { + -readonly [P in keyof T]: Mutable; +}; + +export class EnvironmentReference implements Environment { + readonly id: string; + + constructor(public internal: Environment) { + this.id = internal.id; + } + + get executable() { + return Object.freeze(this.internal.executable); + } + + get environment() { + return Object.freeze(this.internal.environment); + } + + get version() { + return Object.freeze(this.internal.version); + } + + get tools() { + return Object.freeze(this.internal.tools); + } + + get path() { + return Object.freeze(this.internal.path); + } + + updateEnv(newInternal: Environment) { + this.internal = newInternal; + } +} + +function getEnvReference(e: Environment) { + let envClass = environmentsReference.get(e.id); + if (!envClass) { + envClass = new EnvironmentReference(e); + } else { + envClass.updateEnv(e); + } + environmentsReference.set(e.id, envClass); + return envClass; +} + +function filterUsingVSCodeContext(e: PythonEnvInfo) { + const folders = getWorkspaceFolders(); + if (e.searchLocation) { + // Only return local environments that are in the currently opened workspace folders. + const envFolderUri = e.searchLocation; + if (folders) { + return folders.some((folder) => isParentPath(envFolderUri.fsPath, folder.uri.fsPath)); + } + return false; + } + return true; +} + +export function buildEnvironmentApi( + discoveryApi: IDiscoveryAPI, + serviceContainer: IServiceContainer, +): IExtensionApi['environments'] { + const interpreterPathService = serviceContainer.get(IInterpreterPathService); + const configService = serviceContainer.get(IConfigurationService); + const disposables = serviceContainer.get(IDisposableRegistry); + const extensions = serviceContainer.get(IExtensions); + const envVarsProvider = serviceContainer.get(IEnvironmentVariablesProvider); + function sendApiTelemetry(apiName: string, args?: unknown) { + extensions + .determineExtensionFromCallStack() + .then((info) => { + sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { + apiName, + extensionId: info.extensionId, + }); + traceVerbose(`Extension ${info.extensionId} accessed ${apiName} with args: ${JSON.stringify(args)}`); + }) + .ignoreErrors(); + } + disposables.push( + discoveryApi.onChanged((e) => { + const env = e.new ?? e.old; + if (!env || !filterUsingVSCodeContext(env)) { + // Filter out environments that are not in the current workspace. + return; + } + if (e.old) { + if (e.new) { + traceVerbose('Python API env change detected', env.id, 'update'); + onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'update', + }, + ]); + } else { + traceVerbose('Python API env change detected', env.id, 'remove'); + onEnvironmentsChanged.fire({ type: 'remove', env: convertEnvInfoAndGetReference(e.old) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.old.executable.filename, e.old.location).path, + type: 'remove', + }, + ]); + } + } else if (e.new) { + traceVerbose('Python API env change detected', env.id, 'add'); + onEnvironmentsChanged.fire({ type: 'add', env: convertEnvInfoAndGetReference(e.new) }); + reportInterpretersChanged([ + { + path: getEnvPath(e.new.executable.filename, e.new.location).path, + type: 'add', + }, + ]); + } + }), + envVarsProvider.onDidEnvironmentVariablesChange((e) => { + onEnvironmentVariablesChanged.fire({ + resource: getWorkspaceFolder(e), + env: envVarsProvider.getEnvironmentVariablesSync(e), + }); + }), + onEnvironmentsChanged, + onEnvironmentVariablesChanged, + ); + + const environmentApi: IExtensionApi['environments'] = { + getEnvironmentVariables: (resource?: Resource) => { + sendApiTelemetry('getEnvironmentVariables'); + resource = resource && 'uri' in resource ? resource.uri : resource; + return envVarsProvider.getEnvironmentVariablesSync(resource); + }, + get onDidEnvironmentVariablesChange() { + sendApiTelemetry('onDidEnvironmentVariablesChange'); + return onEnvironmentVariablesChanged.event; + }, + getActiveEnvironmentPath(resource?: Resource) { + sendApiTelemetry('getActiveEnvironmentPath'); + resource = resource && 'uri' in resource ? resource.uri : resource; + const path = configService.getSettings(resource).pythonPath; + const id = path === 'python' ? 'DEFAULT_PYTHON' : getEnvID(path); + return { + id, + path, + }; + }, + updateActiveEnvironmentPath(env: Environment | EnvironmentPath | string, resource?: Resource): Promise { + sendApiTelemetry('updateActiveEnvironmentPath'); + const path = typeof env !== 'string' ? env.path : env; + resource = resource && 'uri' in resource ? resource.uri : resource; + return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); + }, + get onDidChangeActiveEnvironmentPath() { + sendApiTelemetry('onDidChangeActiveEnvironmentPath'); + return onDidActiveInterpreterChangedEvent.event; + }, + resolveEnvironment: async (env: Environment | EnvironmentPath | string) => { + if (!workspace.isTrusted) { + throw new Error('Not allowed to resolve environment in an untrusted workspace'); + } + let path = typeof env !== 'string' ? env.path : env; + if (pathUtils.basename(path) === path) { + // Value can be `python`, `python3`, `python3.9` etc. + // This case could eventually be handled by the internal discovery API itself. + const pythonExecutionFactory = serviceContainer.get(IPythonExecutionFactory); + const pythonExecutionService = await pythonExecutionFactory.create({ pythonPath: path }); + const fullyQualifiedPath = await pythonExecutionService.getExecutablePath().catch((ex) => { + traceError('Cannot resolve full path', ex); + return undefined; + }); + // Python path is invalid or python isn't installed. + if (!fullyQualifiedPath) { + return undefined; + } + path = fullyQualifiedPath; + } + sendApiTelemetry('resolveEnvironment', env); + return resolveEnvironment(path, discoveryApi); + }, + get known(): Environment[] { + sendApiTelemetry('known'); + return discoveryApi + .getEnvs() + .filter((e) => filterUsingVSCodeContext(e)) + .map((e) => convertEnvInfoAndGetReference(e)); + }, + async refreshEnvironments(options?: RefreshOptions) { + if (!workspace.isTrusted) { + traceError('Not allowed to refresh environments in an untrusted workspace'); + return; + } + await discoveryApi.triggerRefresh(undefined, { + ifNotTriggerredAlready: !options?.forceRefresh, + }); + sendApiTelemetry('refreshEnvironments'); + }, + get onDidChangeEnvironments() { + sendApiTelemetry('onDidChangeEnvironments'); + return onEnvironmentsChanged.event; + }, + }; + return environmentApi; +} + +async function resolveEnvironment(path: string, discoveryApi: IDiscoveryAPI): Promise { + const env = await discoveryApi.resolveEnv(path); + if (!env) { + return undefined; + } + const resolvedEnv = getEnvReference(convertCompleteEnvInfo(env)) as ResolvedEnvironment; + if (resolvedEnv.version?.major === -1 || resolvedEnv.version?.minor === -1 || resolvedEnv.version?.micro === -1) { + traceError(`Invalid version for ${path}: ${JSON.stringify(env)}`); + } + return resolvedEnv; +} + +export function convertCompleteEnvInfo(env: PythonEnvInfo): ResolvedEnvironment { + const version = { ...env.version, sysVersion: env.version.sysVersion }; + let tool = convertKind(env.kind); + if (env.type && !tool) { + tool = 'Unknown'; + } + const { path } = getEnvPath(env.executable.filename, env.location); + const resolvedEnv: ResolvedEnvironment = { + path, + id: env.id!, + executable: { + uri: env.executable.filename === 'python' ? undefined : Uri.file(env.executable.filename), + bitness: convertBitness(env.arch), + sysPrefix: env.executable.sysPrefix, + }, + environment: env.type + ? { + type: convertEnvType(env.type), + name: env.name === '' ? undefined : env.name, + folderUri: Uri.file(env.location), + workspaceFolder: getWorkspaceFolder(env.searchLocation), + } + : undefined, + version: env.executable.filename === 'python' ? undefined : (version as ResolvedEnvironment['version']), + tools: tool ? [tool] : [], + }; + return resolvedEnv; +} + +function convertEnvType(envType: PythonEnvType): EnvironmentType { + if (envType === PythonEnvType.Conda) { + return 'Conda'; + } + if (envType === PythonEnvType.Virtual) { + return 'VirtualEnvironment'; + } + return 'Unknown'; +} + +function convertKind(kind: PythonEnvKind): EnvironmentTools | undefined { + switch (kind) { + case PythonEnvKind.Venv: + return 'Venv'; + case PythonEnvKind.Pipenv: + return 'Pipenv'; + case PythonEnvKind.Poetry: + return 'Poetry'; + case PythonEnvKind.VirtualEnvWrapper: + return 'VirtualEnvWrapper'; + case PythonEnvKind.VirtualEnv: + return 'VirtualEnv'; + case PythonEnvKind.Conda: + return 'Conda'; + case PythonEnvKind.Pyenv: + return 'Pyenv'; + default: + return undefined; + } +} + +export function convertEnvInfo(env: PythonEnvInfo): Environment { + const convertedEnv = convertCompleteEnvInfo(env) as Mutable; + if (convertedEnv.executable.sysPrefix === '') { + convertedEnv.executable.sysPrefix = undefined; + } + if (convertedEnv.version?.sysVersion === '') { + convertedEnv.version.sysVersion = undefined; + } + if (convertedEnv.version?.major === -1) { + convertedEnv.version.major = undefined; + } + if (convertedEnv.version?.micro === -1) { + convertedEnv.version.micro = undefined; + } + if (convertedEnv.version?.minor === -1) { + convertedEnv.version.minor = undefined; + } + return convertedEnv as Environment; +} + +function convertEnvInfoAndGetReference(env: PythonEnvInfo): Environment { + return getEnvReference(convertEnvInfo(env)); +} + +function convertBitness(arch: Architecture) { + switch (arch) { + case Architecture.x64: + return '64-bit'; + case Architecture.x86: + return '32-bit'; + default: + return 'Unknown'; + } +} + +function getEnvID(path: string) { + return normCasePath(path); +} diff --git a/extensions/positron-python/src/client/extension.ts b/extensions/positron-python/src/client/extension.ts index 67710b73c8b0..5fcb63e2d322 100644 --- a/extensions/positron-python/src/client/extension.ts +++ b/extensions/positron-python/src/client/extension.ts @@ -42,10 +42,10 @@ import { IStartupDurations } from './types'; import { runAfterActivation } from './common/utils/runAfterActivation'; import { IInterpreterService } from './interpreter/contracts'; import { IExtensionApi } from './apiTypes'; -import { buildProposedApi } from './proposedApi'; import { WorkspaceService } from './common/application/workspace'; import { disposeAll } from './common/utils/resourceLifecycle'; import { ProposedExtensionAPI } from './proposedApiTypes'; +import { buildProposedApi } from './proposedApi'; durations.codeLoadingTime = stopWatch.elapsedTime; @@ -156,7 +156,12 @@ async function activateUnsafe( runAfterActivation(); }); - const api = buildApi(activationPromise, ext.legacyIOC.serviceManager, ext.legacyIOC.serviceContainer); + const api = buildApi( + activationPromise, + ext.legacyIOC.serviceManager, + ext.legacyIOC.serviceContainer, + components.pythonEnvs, + ); const proposedApi = buildProposedApi(components.pythonEnvs, ext.legacyIOC.serviceContainer); return [{ ...api, ...proposedApi }, activationPromise, ext.legacyIOC.serviceContainer]; } diff --git a/extensions/positron-python/src/client/extensionActivation.ts b/extensions/positron-python/src/client/extensionActivation.ts index 1f2e3e94cefe..4b2a41105d77 100644 --- a/extensions/positron-python/src/client/extensionActivation.ts +++ b/extensions/positron-python/src/client/extensionActivation.ts @@ -62,6 +62,8 @@ 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'; +import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt'; +import { registerPyProjectTomlCreateEnvFeatures } from './pythonEnvironments/creation/pyprojectTomlCreateEnv'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -105,6 +107,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): ); const pathUtils = ext.legacyIOC.serviceContainer.get(IPathUtils); registerCreateEnvironmentFeatures(ext.disposables, interpreterQuickPick, interpreterPathService, pathUtils); + registerPyProjectTomlCreateEnvFeatures(ext.disposables); } /// ////////////////////////// @@ -206,13 +209,15 @@ async function activateLegacy(ext: ExtensionState): Promise { }); // register a dynamic configuration provider for 'python' debug type - context.subscriptions.push( + disposables.push( debug.registerDebugConfigurationProvider( DebuggerTypeName, serviceContainer.get(IDynamicDebugConfigurationService), DebugConfigurationProviderTriggerKind.Dynamic, ), ); + + registerInstallFormatterPrompt(serviceContainer); } } diff --git a/extensions/positron-python/src/client/formatters/baseFormatter.ts b/extensions/positron-python/src/client/formatters/baseFormatter.ts index b91a6ac85def..bffa03a8b1e1 100644 --- a/extensions/positron-python/src/client/formatters/baseFormatter.ts +++ b/extensions/positron-python/src/client/formatters/baseFormatter.ts @@ -11,6 +11,7 @@ import { IServiceContainer } from '../ioc/types'; import { traceError, traceLog } from '../logging'; import { getTempFileWithDocumentContents, getTextEditsFromPatch } from './../common/editor'; import { IFormatterHelper } from './types'; +import { IInstallFormatterPrompt } from '../providers/prompts/types'; export abstract class BaseFormatter { protected readonly workspace: IWorkspaceService; @@ -103,13 +104,16 @@ export abstract class BaseFormatter { let customError = `Formatting with ${this.Id} failed.`; if (isNotInstalledError(error)) { - const installer = this.serviceContainer.get(IInstaller); - const isInstalled = await installer.isInstalled(this.product, resource); - if (!isInstalled) { - customError += `\nYou could either install the '${this.Id}' formatter, turn it off or use another formatter.`; - installer - .promptToInstall(this.product, resource) - .catch((ex) => traceError('Python Extension: promptToInstall', ex)); + const prompt = this.serviceContainer.get(IInstallFormatterPrompt); + if (!(await prompt.showInstallFormatterPrompt(resource))) { + const installer = this.serviceContainer.get(IInstaller); + const isInstalled = await installer.isInstalled(this.product, resource); + if (!isInstalled) { + customError += `\nYou could either install the '${this.Id}' formatter, turn it off or use another formatter.`; + installer + .promptToInstall(this.product, resource) + .catch((ex) => traceError('Python Extension: promptToInstall', ex)); + } } } diff --git a/extensions/positron-python/src/client/interpreter/activation/service.ts b/extensions/positron-python/src/client/interpreter/activation/service.ts index e4074b070853..897dad9cb75d 100644 --- a/extensions/positron-python/src/client/interpreter/activation/service.ts +++ b/extensions/positron-python/src/client/interpreter/activation/service.ts @@ -1,6 +1,9 @@ +/* eslint-disable max-classes-per-file */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. + 'use strict'; + import '../../common/extensions'; import { inject, injectable } from 'inversify'; @@ -32,6 +35,7 @@ import { } from '../../logging'; import { Conda } from '../../pythonEnvironments/common/environmentManagers/conda'; import { StopWatch } from '../../common/utils/stopWatch'; +import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; const ENVIRONMENT_PREFIX = 'e8b39361-0157-4923-80e1-22d70d46dee6'; const CACHE_DURATION = 10 * 60 * 1000; @@ -59,15 +63,19 @@ const condaRetryMessages = [ */ export class EnvironmentActivationServiceCache { private static useStatic = false; + private static staticMap = new Map>(); + private normalMap = new Map>(); - public static forceUseStatic() { + public static forceUseStatic(): void { EnvironmentActivationServiceCache.useStatic = true; } - public static forceUseNormal() { + + public static forceUseNormal(): void { EnvironmentActivationServiceCache.useStatic = false; } + public get(key: string): InMemoryCache | undefined { if (EnvironmentActivationServiceCache.useStatic) { return EnvironmentActivationServiceCache.staticMap.get(key); @@ -75,7 +83,7 @@ export class EnvironmentActivationServiceCache { return this.normalMap.get(key); } - public set(key: string, value: InMemoryCache) { + public set(key: string, value: InMemoryCache): void { if (EnvironmentActivationServiceCache.useStatic) { EnvironmentActivationServiceCache.staticMap.set(key, value); } else { @@ -83,7 +91,7 @@ export class EnvironmentActivationServiceCache { } } - public delete(key: string) { + public delete(key: string): void { if (EnvironmentActivationServiceCache.useStatic) { EnvironmentActivationServiceCache.staticMap.delete(key); } else { @@ -91,7 +99,7 @@ export class EnvironmentActivationServiceCache { } } - public clear() { + public clear(): void { // Don't clear during a test as the environment isn't going to change if (!EnvironmentActivationServiceCache.useStatic) { this.normalMap.clear(); @@ -102,7 +110,9 @@ export class EnvironmentActivationServiceCache { @injectable() export class EnvironmentActivationService implements IEnvironmentActivationService, IDisposable { private readonly disposables: IDisposable[] = []; + private readonly activatedEnvVariablesCache = new EnvironmentActivationServiceCache(); + constructor( @inject(ITerminalHelper) private readonly helper: ITerminalHelper, @inject(IPlatformService) private readonly platform: IPlatformService, @@ -117,29 +127,25 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi this, this.disposables, ); - - this.interpreterService.onDidChangeInterpreter( - () => this.activatedEnvVariablesCache.clear(), - this, - this.disposables, - ); } public dispose(): void { this.disposables.forEach((d) => d.dispose()); } + @traceDecoratorVerbose('getActivatedEnvironmentVariables', TraceOptions.Arguments) public async getActivatedEnvironmentVariables( resource: Resource, interpreter?: PythonEnvironment, allowExceptions?: boolean, + shell?: string, ): Promise { const stopWatch = new StopWatch(); // Cache key = resource + interpreter. const workspaceKey = this.workspace.getWorkspaceFolderIdentifier(resource); interpreter = interpreter ?? (await this.interpreterService.getActiveInterpreter(resource)); const interpreterPath = this.platform.isWindows ? interpreter?.path.toLowerCase() : interpreter?.path; - const cacheKey = `${workspaceKey}_${interpreterPath}`; + const cacheKey = `${workspaceKey}_${interpreterPath}_${shell}`; if (this.activatedEnvVariablesCache.get(cacheKey)?.hasData) { return this.activatedEnvVariablesCache.get(cacheKey)!.data; @@ -147,7 +153,7 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi // Cache only if successful, else keep trying & failing if necessary. const cache = new InMemoryCache(CACHE_DURATION); - return this.getActivatedEnvironmentVariablesImpl(resource, interpreter, allowExceptions) + return this.getActivatedEnvironmentVariablesImpl(resource, interpreter, allowExceptions, shell) .then((vars) => { cache.data = vars; this.activatedEnvVariablesCache.set(cacheKey, cache); @@ -167,6 +173,7 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi throw ex; }); } + public async getEnvironmentActivationShellCommands( resource: Resource, interpreter?: PythonEnvironment, @@ -177,23 +184,29 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi } return this.helper.getEnvironmentActivationShellCommands(resource, shellInfo.shellType, interpreter); } + public async getActivatedEnvironmentVariablesImpl( resource: Resource, interpreter?: PythonEnvironment, allowExceptions?: boolean, + shell?: string, ): Promise { - const shellInfo = defaultShells[this.platform.osType]; + let shellInfo = defaultShells[this.platform.osType]; if (!shellInfo) { - return; + return undefined; + } + if (shell) { + const customShellType = identifyShellFromShellPath(shell); + shellInfo = { shellType: customShellType, shell }; } try { let command: string | undefined; - let [args, parse] = internalScripts.printEnvVariables(); + const [args, parse] = internalScripts.printEnvVariables(); args.forEach((arg, i) => { args[i] = arg.toCommandArgumentForPythonExt(); }); if (interpreter?.envType === EnvironmentType.Conda) { - const conda = await Conda.getConda(); + const conda = await Conda.getConda(shell); const pythonArgv = await conda?.getRunPythonArgs({ name: interpreter.envName, prefix: interpreter.envPath ?? '', @@ -211,10 +224,10 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi ); traceVerbose(`Activation Commands received ${activationCommands} for shell ${shellInfo.shell}`); if (!activationCommands || !Array.isArray(activationCommands) || activationCommands.length === 0) { - return; + return undefined; } // Run the activate command collect the environment from it. - const activationCommand = this.fixActivationCommands(activationCommands).join(' && '); + const activationCommand = fixActivationCommands(activationCommands).join(' && '); // In order to make sure we know where the environment output is, // put in a dummy echo we can look for command = `${activationCommand} && echo '${ENVIRONMENT_PREFIX}' && python ${args.join(' ')}`; @@ -306,15 +319,13 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi throw e; } } + return undefined; } - protected fixActivationCommands(commands: string[]): string[] { - // Replace 'source ' with '. ' as that works in shell exec - return commands.map((cmd) => cmd.replace(/^source\s+/, '. ')); - } @traceDecoratorError('Failed to parse Environment variables') @traceDecoratorVerbose('parseEnvironmentOutput', TraceOptions.None) - protected parseEnvironmentOutput(output: string, parse: (out: string) => NodeJS.ProcessEnv | undefined) { + // eslint-disable-next-line class-methods-use-this + private parseEnvironmentOutput(output: string, parse: (out: string) => NodeJS.ProcessEnv | undefined) { if (output.indexOf(ENVIRONMENT_PREFIX) === -1) { return parse(output); } @@ -323,3 +334,8 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi return parse(js); } } + +function fixActivationCommands(commands: string[]): string[] { + // Replace 'source ' with '. ' as that works in shell exec + return commands.map((cmd) => cmd.replace(/^source\s+/, '. ')); +} diff --git a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts new file mode 100644 index 000000000000..f5af71b3f2ca --- /dev/null +++ b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { ProgressOptions, ProgressLocation } from 'vscode'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { IApplicationShell, IApplicationEnvironment } from '../../common/application/types'; +import { inTerminalEnvVarExperiment } from '../../common/experiments/helpers'; +import { IPlatformService } from '../../common/platform/types'; +import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; +import { IExtensionContext, IExperimentService, Resource, IDisposableRegistry } from '../../common/types'; +import { Deferred, createDeferred } from '../../common/utils/async'; +import { Interpreters } from '../../common/utils/localize'; +import { traceDecoratorVerbose, traceVerbose } from '../../logging'; +import { IInterpreterService } from '../contracts'; +import { defaultShells } from './service'; +import { IEnvironmentActivationService } from './types'; + +@injectable() +export class TerminalEnvVarCollectionService implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { + untrustedWorkspace: false, + virtualWorkspace: false, + }; + + private deferred: Deferred | undefined; + + private previousEnvVars = _normCaseKeys(process.env); + + constructor( + @inject(IPlatformService) private readonly platform: IPlatformService, + @inject(IInterpreterService) private interpreterService: IInterpreterService, + @inject(IExtensionContext) private context: IExtensionContext, + @inject(IApplicationShell) private shell: IApplicationShell, + @inject(IExperimentService) private experimentService: IExperimentService, + @inject(IApplicationEnvironment) private applicationEnvironment: IApplicationEnvironment, + @inject(IDisposableRegistry) private disposables: IDisposableRegistry, + @inject(IEnvironmentActivationService) private environmentActivationService: IEnvironmentActivationService, + ) {} + + public async activate(): Promise { + if (!inTerminalEnvVarExperiment(this.experimentService)) { + this.context.environmentVariableCollection.clear(); + return; + } + this.interpreterService.onDidChangeInterpreter( + async (resource) => { + this.showProgress(); + await this._applyCollection(resource); + this.hideProgress(); + }, + this, + this.disposables, + ); + this.applicationEnvironment.onDidChangeShell( + async (shell: string) => { + this.showProgress(); + // Pass in the shell where known instead of relying on the application environment, because of bug + // on VSCode: https://github.com/microsoft/vscode/issues/160694 + await this._applyCollection(undefined, shell); + this.hideProgress(); + }, + this, + this.disposables, + ); + + this._applyCollection(undefined).ignoreErrors(); + } + + public async _applyCollection(resource: Resource, shell = this.applicationEnvironment.shell): Promise { + const env = await this.environmentActivationService.getActivatedEnvironmentVariables( + resource, + undefined, + undefined, + shell, + ); + if (!env) { + const shellType = identifyShellFromShellPath(shell); + const defaultShell = defaultShells[this.platform.osType]; + if (defaultShell?.shellType !== shellType) { + // Commands to fetch env vars may fail in custom shells due to unknown reasons, in that case + // fallback to default shells as they are known to work better. + await this._applyCollection(resource, defaultShell?.shell); + return; + } + this.context.environmentVariableCollection.clear(); + this.previousEnvVars = _normCaseKeys(process.env); + return; + } + const previousEnv = this.previousEnvVars; + this.previousEnvVars = env; + Object.keys(env).forEach((key) => { + const value = env[key]; + const prevValue = previousEnv[key]; + if (prevValue !== value) { + if (value !== undefined) { + traceVerbose(`Setting environment variable ${key} in collection to ${value}`); + this.context.environmentVariableCollection.replace(key, value); + } else { + traceVerbose(`Clearing environment variable ${key} from collection`); + this.context.environmentVariableCollection.delete(key); + } + } + }); + Object.keys(previousEnv).forEach((key) => { + // If the previous env var is not in the current env, clear it from collection. + if (!(key in env)) { + traceVerbose(`Clearing environment variable ${key} from collection`); + this.context.environmentVariableCollection.delete(key); + } + }); + } + + @traceDecoratorVerbose('Display activating terminals') + private showProgress(): void { + if (!this.deferred) { + this.createProgress(); + } + } + + @traceDecoratorVerbose('Hide activating terminals') + private hideProgress(): void { + if (this.deferred) { + this.deferred.resolve(); + this.deferred = undefined; + } + } + + private createProgress() { + const progressOptions: ProgressOptions = { + location: ProgressLocation.Window, + title: Interpreters.activatingTerminals, + }; + this.shell.withProgress(progressOptions, () => { + this.deferred = createDeferred(); + return this.deferred.promise; + }); + } +} + +export function _normCaseKeys(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + Object.keys(env).forEach((key) => { + // `os.environ` script used to get env vars normalizes keys to upper case: + // https://github.com/python/cpython/issues/101754 + // So convert `process.env` keys to upper case to match. + result[key.toUpperCase()] = env[key]; + }); + return result; +} diff --git a/extensions/positron-python/src/client/interpreter/activation/types.ts b/extensions/positron-python/src/client/interpreter/activation/types.ts index 9508147a3552..d8e4ae16dbca 100644 --- a/extensions/positron-python/src/client/interpreter/activation/types.ts +++ b/extensions/positron-python/src/client/interpreter/activation/types.ts @@ -12,6 +12,7 @@ export interface IEnvironmentActivationService { resource: Resource, interpreter?: PythonEnvironment, allowExceptions?: boolean, + shell?: string, ): Promise; getEnvironmentActivationShellCommands( resource: Resource, diff --git a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/index.ts b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/index.ts index fef63a49e6d2..d6d423c1eab8 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/index.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/index.ts @@ -34,7 +34,7 @@ export class InstallPythonCommand implements IExtensionSingleActivationService { if (version.major > 8) { // OS is not Windows 8, ms-windows-store URIs are available: // https://docs.microsoft.com/en-us/windows/uwp/launch-resume/launch-store-app - this.browserService.launch('ms-windows-store://pdp/?ProductId=9PJPW5LDXLZ5'); + this.browserService.launch('ms-windows-store://pdp/?ProductId=9NRWMJP3717K'); return; } } diff --git a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/installPythonViaTerminal.ts b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/installPythonViaTerminal.ts index 7587c997d6c5..9da7284a3bea 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/installPythonViaTerminal.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/installPython/installPythonViaTerminal.ts @@ -55,9 +55,13 @@ export class InstallPythonViaTerminal implements IExtensionSingleActivationServi public async _installPythonOnUnix(os: OSType.Linux | OSType.OSX): Promise { const commands = await this.getCommands(os); + const installMessage = + os === OSType.OSX + ? Interpreters.installPythonTerminalMacMessage + : Interpreters.installPythonTerminalMessageLinux; const terminal = this.terminalManager.createTerminal({ name: 'Python', - message: commands.length ? undefined : Interpreters.installPythonTerminalMessage, + message: commands.length ? undefined : installMessage, }); terminal.show(true); await waitForTerminalToStartup(); @@ -69,24 +73,17 @@ export class InstallPythonViaTerminal implements IExtensionSingleActivationServi private async getCommands(os: OSType.Linux | OSType.OSX) { if (os === OSType.OSX) { - return this.packageManagerCommands[PackageManagers.brew]; + return this.getCommandsForPackageManagers([PackageManagers.brew]); } - return this.getCommandsForLinux(); + if (os === OSType.Linux) { + return this.getCommandsForPackageManagers([PackageManagers.apt, PackageManagers.dnf]); + } + throw new Error('OS not supported'); } - private async getCommandsForLinux() { - for (const packageManager of [PackageManagers.apt, PackageManagers.dnf]) { - let isPackageAvailable = false; - try { - const which = require('which') as typeof whichTypes; - const resolvedPath = await which(packageManager); - traceVerbose(`Resolved path to ${packageManager} module:`, resolvedPath); - isPackageAvailable = resolvedPath.trim().length > 0; - } catch (ex) { - traceVerbose(`${packageManager} not found`, ex); - isPackageAvailable = false; - } - if (isPackageAvailable) { + private async getCommandsForPackageManagers(packageManagers: PackageManagers[]) { + for (const packageManager of packageManagers) { + if (await isPackageAvailable(packageManager)) { return this.packageManagerCommands[packageManager]; } } @@ -94,6 +91,18 @@ export class InstallPythonViaTerminal implements IExtensionSingleActivationServi } } +async function isPackageAvailable(packageManager: PackageManagers) { + try { + const which = require('which') as typeof whichTypes; + const resolvedPath = await which(packageManager); + traceVerbose(`Resolved path to ${packageManager} module:`, resolvedPath); + return resolvedPath.trim().length > 0; + } catch (ex) { + traceVerbose(`${packageManager} not found`, ex); + return false; + } +} + async function waitForTerminalToStartup() { // Sometimes the terminal takes some time to start up before it can start accepting input. await sleep(100); diff --git a/extensions/positron-python/src/client/interpreter/contracts.ts b/extensions/positron-python/src/client/interpreter/contracts.ts index ec504802bcfc..bfaebd235f19 100644 --- a/extensions/positron-python/src/client/interpreter/contracts.ts +++ b/extensions/positron-python/src/client/interpreter/contracts.ts @@ -76,7 +76,7 @@ export interface IInterpreterService { readonly refreshPromise: Promise | undefined; readonly onDidChangeInterpreters: Event; onDidChangeInterpreterConfiguration: Event; - onDidChangeInterpreter: Event; + onDidChangeInterpreter: Event; onDidChangeInterpreterInformation: Event; /** * Note this API does not trigger the refresh but only works with the current refresh if any. Information diff --git a/extensions/positron-python/src/client/interpreter/interpreterService.ts b/extensions/positron-python/src/client/interpreter/interpreterService.ts index 151b86508b8c..3cfb651977bb 100644 --- a/extensions/positron-python/src/client/interpreter/interpreterService.ts +++ b/extensions/positron-python/src/client/interpreter/interpreterService.ts @@ -9,6 +9,7 @@ import { ProgressLocation, ProgressOptions, Uri, + WorkspaceFolder, } from 'vscode'; import '../common/extensions'; import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../common/application/types'; @@ -31,7 +32,7 @@ import { } from './contracts'; import { traceError, traceLog } from '../logging'; import { Commands, PYTHON_LANGUAGE } from '../common/constants'; -import { reportActiveInterpreterChanged } from '../proposedApi'; +import { reportActiveInterpreterChanged } from '../environmentApi'; import { IPythonExecutionFactory } from '../common/process/types'; import { Interpreters } from '../common/utils/localize'; import { sendTelemetryEvent } from '../telemetry'; @@ -58,7 +59,7 @@ export class InterpreterService implements Disposable, IInterpreterService { return this.pyenvs.getRefreshPromise(); } - public get onDidChangeInterpreter(): Event { + public get onDidChangeInterpreter(): Event { return this.didChangeInterpreterEmitter.event; } @@ -80,7 +81,7 @@ export class InterpreterService implements Disposable, IInterpreterService { private readonly interpreterPathService: IInterpreterPathService; - private readonly didChangeInterpreterEmitter = new EventEmitter(); + private readonly didChangeInterpreterEmitter = new EventEmitter(); private readonly didChangeInterpreterInformation = new EventEmitter(); @@ -96,7 +97,13 @@ export class InterpreterService implements Disposable, IInterpreterService { public async refresh(resource?: Uri): Promise { const interpreterDisplay = this.serviceContainer.get(IInterpreterDisplay); await interpreterDisplay.refresh(resource); - this.ensureEnvironmentContainsPython(this.configService.getSettings(resource).pythonPath).ignoreErrors(); + const workspaceFolder = this.serviceContainer + .get(IWorkspaceService) + .getWorkspaceFolder(resource); + this.ensureEnvironmentContainsPython( + this.configService.getSettings(resource).pythonPath, + workspaceFolder, + ).ignoreErrors(); } public initialize(): void { @@ -226,19 +233,22 @@ export class InterpreterService implements Disposable, IInterpreterService { this.didChangeInterpreterConfigurationEmitter.fire(resource); if (this._pythonPathSetting === '' || this._pythonPathSetting !== pySettings.pythonPath) { this._pythonPathSetting = pySettings.pythonPath; - this.didChangeInterpreterEmitter.fire(); + this.didChangeInterpreterEmitter.fire(resource); + const workspaceFolder = this.serviceContainer + .get(IWorkspaceService) + .getWorkspaceFolder(resource); reportActiveInterpreterChanged({ path: pySettings.pythonPath, - resource: this.serviceContainer.get(IWorkspaceService).getWorkspaceFolder(resource), + resource: workspaceFolder, }); const interpreterDisplay = this.serviceContainer.get(IInterpreterDisplay); interpreterDisplay.refresh().catch((ex) => traceError('Python Extension: display.refresh', ex)); - await this.ensureEnvironmentContainsPython(this._pythonPathSetting); + await this.ensureEnvironmentContainsPython(this._pythonPathSetting, workspaceFolder); } } @cache(-1, true) - private async ensureEnvironmentContainsPython(pythonPath: string) { + private async ensureEnvironmentContainsPython(pythonPath: string, workspaceFolder: WorkspaceFolder | undefined) { const installer = this.serviceContainer.get(IInstaller); if (!(await installer.isInstalled(Product.python))) { // If Python is not installed into the environment, install it. @@ -251,7 +261,18 @@ export class InterpreterService implements Disposable, IInterpreterService { traceLog('Conda envs without Python are known to not work well; fixing conda environment...'); const promise = installer.install(Product.python, await this.getInterpreterDetails(pythonPath)); shell.withProgress(progressOptions, () => promise); - promise.then(() => this.triggerRefresh().ignoreErrors()); + promise + .then(async () => { + // Fetch interpreter details so the cache is updated to include the newly installed Python. + await this.getInterpreterDetails(pythonPath); + // Fire an event as the executable for the environment has changed. + this.didChangeInterpreterEmitter.fire(workspaceFolder?.uri); + reportActiveInterpreterChanged({ + path: pythonPath, + resource: workspaceFolder, + }); + }) + .ignoreErrors(); } } } diff --git a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts index 422776bd5e43..15dd6de7b722 100644 --- a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts +++ b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts @@ -6,6 +6,7 @@ import { IExtensionActivationService, IExtensionSingleActivationService } from '../activation/types'; import { IServiceManager } from '../ioc/types'; import { EnvironmentActivationService } from './activation/service'; +import { TerminalEnvVarCollectionService } from './activation/terminalEnvVarCollectionService'; import { IEnvironmentActivationService } from './activation/types'; import { InterpreterAutoSelectionService } from './autoSelection/index'; import { InterpreterAutoSelectionProxyService } from './autoSelection/proxy'; @@ -108,4 +109,8 @@ export function registerTypes(serviceManager: IServiceManager): void { IEnvironmentActivationService, EnvironmentActivationService, ); + serviceManager.addSingleton( + IExtensionSingleActivationService, + TerminalEnvVarCollectionService, + ); } diff --git a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts index 556ff93f240a..16da174f3178 100644 --- a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts +++ b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts @@ -9,7 +9,7 @@ import { dirname } from 'path'; import { CancellationToken, Event, Extension, Memento, Uri } from 'vscode'; import type { SemVer } from 'semver'; import { IWorkspaceService } from '../common/application/types'; -import { JUPYTER_EXTENSION_ID } from '../common/constants'; +import { JUPYTER_EXTENSION_ID, PYLANCE_EXTENSION_ID } from '../common/constants'; import { InterpreterUri, ModuleInstallFlags } from '../common/installer/types'; import { GLOBAL_MEMENTO, @@ -34,7 +34,7 @@ import { } from '../interpreter/contracts'; import { PythonEnvironment } from '../pythonEnvironments/info'; import { IDataViewerDataProvider, IJupyterUriProvider } from './types'; - +import { PylanceApi } from '../activation/node/pylanceApi'; /** * This allows Python extension to update Product enum without breaking Jupyter. * I.e. we have a strict contract, else using numbers (in enums) is bound to break across products. @@ -63,7 +63,7 @@ type PythonApiForJupyterExtension = { /** * IInterpreterService */ - onDidChangeInterpreter: Event; + onDidChangeInterpreter: Event; /** * IInterpreterService */ @@ -184,6 +184,8 @@ type JupyterExtensionApi = { export class JupyterExtensionIntegration { private jupyterExtension: Extension | undefined; + private pylanceExtension: Extension | undefined; + private jupyterPythonPathFunction: ((uri: Uri) => Promise) | undefined; private getNotebookUriForTextDocumentUriFunction: ((textDocumentUri: Uri) => Uri | undefined) | undefined; @@ -300,6 +302,16 @@ export class JupyterExtensionIntegration { } private async getExtensionApi(): Promise { + if (!this.pylanceExtension) { + const pylanceExtension = this.extensions.getExtension(PYLANCE_EXTENSION_ID); + + if (pylanceExtension && !pylanceExtension.isActive) { + await pylanceExtension.activate(); + } + + this.pylanceExtension = pylanceExtension; + } + if (!this.jupyterExtension) { const jupyterExtension = this.extensions.getExtension(JUPYTER_EXTENSION_ID); if (!jupyterExtension) { @@ -316,8 +328,18 @@ export class JupyterExtensionIntegration { return undefined; } + private getPylanceApi(): PylanceApi | undefined { + const api = this.pylanceExtension?.exports; + return api && api.notebook && api.client && api.client.isEnabled() ? api : undefined; + } + private registerJupyterPythonPathFunction(func: (uri: Uri) => Promise) { this.jupyterPythonPathFunction = func; + + const api = this.getPylanceApi(); + if (api) { + api.notebook!.registerJupyterPythonPathFunction(func); + } } public getJupyterPythonPathFunction(): ((uri: Uri) => Promise) | undefined { @@ -326,6 +348,11 @@ export class JupyterExtensionIntegration { public registerGetNotebookUriForTextDocumentUriFunction(func: (textDocumentUri: Uri) => Uri | undefined): void { this.getNotebookUriForTextDocumentUriFunction = func; + + const api = this.getPylanceApi(); + if (api) { + api.notebook!.registerGetNotebookUriForTextDocumentUriFunction(func); + } } public getGetNotebookUriForTextDocumentUriFunction(): ((textDocumentUri: Uri) => Uri | undefined) | undefined { diff --git a/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts b/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts index 3865886880b2..c7df3318e3b7 100644 --- a/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts +++ b/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts @@ -5,7 +5,6 @@ import { promptForPylanceInstall } from '../activation/common/languageServerChan import { NodeLanguageServerAnalysisOptions } from '../activation/node/analysisOptions'; import { NodeLanguageClientFactory } from '../activation/node/languageClientFactory'; import { NodeLanguageServerProxy } from '../activation/node/languageServerProxy'; -import { LspNotebooksExperiment } from '../activation/node/lspNotebooksExperiment'; import { NodeLanguageServerManager } from '../activation/node/manager'; import { ILanguageServerOutputChannel } from '../activation/types'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../common/application/types'; @@ -49,13 +48,11 @@ export class PylanceLSExtensionManager implements IDisposable, ILanguageServerEx fileSystem: IFileSystem, private readonly extensions: IExtensions, readonly applicationShell: IApplicationShell, - lspNotebooksExperiment: LspNotebooksExperiment, ) { this.analysisOptions = new NodeLanguageServerAnalysisOptions( outputChannel, workspaceService, experimentService, - lspNotebooksExperiment, ); this.clientFactory = new NodeLanguageClientFactory(fileSystem, extensions); this.serverProxy = new NodeLanguageServerProxy( diff --git a/extensions/positron-python/src/client/languageServer/watcher.ts b/extensions/positron-python/src/client/languageServer/watcher.ts index c71c33b7ad28..d3eccb71144c 100644 --- a/extensions/positron-python/src/client/languageServer/watcher.ts +++ b/extensions/positron-python/src/client/languageServer/watcher.ts @@ -27,7 +27,8 @@ import { JediLSExtensionManager } from './jediLSExtensionManager'; import { NoneLSExtensionManager } from './noneLSExtensionManager'; import { PylanceLSExtensionManager } from './pylanceLSExtensionManager'; import { ILanguageServerExtensionManager, ILanguageServerWatcher } from './types'; -import { LspNotebooksExperiment } from '../activation/node/lspNotebooksExperiment'; +import { sendTelemetryEvent } from '../telemetry'; +import { EventName } from '../telemetry/constants'; @injectable() /** @@ -63,7 +64,6 @@ export class LanguageServerWatcher implements IExtensionActivationService, ILang @inject(IFileSystem) private readonly fileSystem: IFileSystem, @inject(IExtensions) private readonly extensions: IExtensions, @inject(IApplicationShell) readonly applicationShell: IApplicationShell, - @inject(LspNotebooksExperiment) private readonly lspNotebooksExperiment: LspNotebooksExperiment, @inject(IDisposableRegistry) readonly disposables: IDisposableRegistry, ) { this.workspaceInterpreters = new Map(); @@ -184,6 +184,7 @@ export class LanguageServerWatcher implements IExtensionActivationService, ILang public async restartLanguageServers(): Promise { this.workspaceLanguageServers.forEach(async (_, resourceString) => { + sendTelemetryEvent(EventName.LANGUAGE_SERVER_RESTART, undefined, { reason: 'notebooksExperiment' }); const resource = Uri.parse(resourceString); await this.stopLanguageServer(resource); await this.startLanguageServer(this.languageServerType, resource); @@ -244,7 +245,6 @@ export class LanguageServerWatcher implements IExtensionActivationService, ILang this.fileSystem, this.extensions, this.applicationShell, - this.lspNotebooksExperiment, ); break; case LanguageServerType.None: @@ -262,11 +262,11 @@ export class LanguageServerWatcher implements IExtensionActivationService, ILang return lsManager; } - private async refreshLanguageServer(resource?: Resource): Promise { + private async refreshLanguageServer(resource?: Resource, forced?: boolean): Promise { const lsResource = this.getWorkspaceUri(resource); const languageServerType = this.configurationService.getSettings(lsResource).languageServer; - if (languageServerType !== this.languageServerType) { + if (languageServerType !== this.languageServerType || forced) { await this.stopLanguageServer(resource); await this.startLanguageServer(languageServerType, lsResource); } @@ -283,6 +283,8 @@ export class LanguageServerWatcher implements IExtensionActivationService, ILang workspacesUris.forEach(async (resource) => { if (event.affectsConfiguration(`python.languageServer`, resource)) { await this.refreshLanguageServer(resource); + } else if (event.affectsConfiguration(`python.analysis.pylanceLspClientEnabled`, resource)) { + await this.refreshLanguageServer(resource, /* forced */ true); } }); } diff --git a/extensions/positron-python/src/client/proposedApi.ts b/extensions/positron-python/src/client/proposedApi.ts index 1b710a888c99..22d53b0201ef 100644 --- a/extensions/positron-python/src/client/proposedApi.ts +++ b/extensions/positron-python/src/client/proposedApi.ts @@ -2,191 +2,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ConfigurationTarget, EventEmitter, Uri, workspace, WorkspaceFolder } from 'vscode'; -import * as pathUtils from 'path'; -import { IConfigurationService, IDisposableRegistry, IExtensions, IInterpreterPathService } from './common/types'; -import { Architecture } from './common/utils/platform'; import { IServiceContainer } from './ioc/types'; -import { - ActiveEnvironmentPathChangeEvent, - Environment, - EnvironmentsChangeEvent, - ProposedExtensionAPI, - ResolvedEnvironment, - RefreshOptions, - Resource, - EnvironmentType, - EnvironmentTools, - EnvironmentPath, - EnvironmentVariablesChangeEvent, -} from './proposedApiTypes'; -import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from './pythonEnvironments/base/info'; -import { getEnvPath } from './pythonEnvironments/base/info/env'; +import { ProposedExtensionAPI } from './proposedApiTypes'; import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; -import { IPythonExecutionFactory } from './common/process/types'; -import { traceError, traceVerbose } from './logging'; -import { isParentPath, normCasePath } from './common/platform/fs-paths'; -import { sendTelemetryEvent } from './telemetry'; -import { EventName } from './telemetry/constants'; -import { - buildDeprecatedProposedApi, - reportActiveInterpreterChangedDeprecated, - reportInterpretersChanged, -} from './deprecatedProposedApi'; +import { buildDeprecatedProposedApi } from './deprecatedProposedApi'; import { DeprecatedProposedAPI } from './deprecatedProposedApiTypes'; -import { IEnvironmentVariablesProvider } from './common/variables/types'; -import { getWorkspaceFolder, getWorkspaceFolders } from './common/vscodeApis/workspaceApis'; - -type ActiveEnvironmentChangeEvent = { - resource: WorkspaceFolder | undefined; - path: string; -}; - -const onDidActiveInterpreterChangedEvent = new EventEmitter(); -export function reportActiveInterpreterChanged(e: ActiveEnvironmentChangeEvent): void { - onDidActiveInterpreterChangedEvent.fire({ id: getEnvID(e.path), path: e.path, resource: e.resource }); - reportActiveInterpreterChangedDeprecated({ path: e.path, resource: e.resource?.uri }); -} - -const onEnvironmentsChanged = new EventEmitter(); -const onEnvironmentVariablesChanged = new EventEmitter(); -const environmentsReference = new Map(); - -/** - * Make all properties in T mutable. - */ -type Mutable = { - -readonly [P in keyof T]: Mutable; -}; - -export class EnvironmentReference implements Environment { - readonly id: string; - - constructor(public internal: Environment) { - this.id = internal.id; - } - - get executable() { - return Object.freeze(this.internal.executable); - } - - get environment() { - return Object.freeze(this.internal.environment); - } - - get version() { - return Object.freeze(this.internal.version); - } - - get tools() { - return Object.freeze(this.internal.tools); - } - - get path() { - return Object.freeze(this.internal.path); - } - - updateEnv(newInternal: Environment) { - this.internal = newInternal; - } -} - -function getEnvReference(e: Environment) { - let envClass = environmentsReference.get(e.id); - if (!envClass) { - envClass = new EnvironmentReference(e); - } else { - envClass.updateEnv(e); - } - environmentsReference.set(e.id, envClass); - return envClass; -} - -function filterUsingVSCodeContext(e: PythonEnvInfo) { - const folders = getWorkspaceFolders(); - if (e.searchLocation) { - // Only return local environments that are in the currently opened workspace folders. - const envFolderUri = e.searchLocation; - if (folders) { - return folders.some((folder) => isParentPath(envFolderUri.fsPath, folder.uri.fsPath)); - } - return false; - } - return true; -} export function buildProposedApi( discoveryApi: IDiscoveryAPI, serviceContainer: IServiceContainer, ): ProposedExtensionAPI { - const interpreterPathService = serviceContainer.get(IInterpreterPathService); - const configService = serviceContainer.get(IConfigurationService); - const disposables = serviceContainer.get(IDisposableRegistry); - const extensions = serviceContainer.get(IExtensions); - const envVarsProvider = serviceContainer.get(IEnvironmentVariablesProvider); - function sendApiTelemetry(apiName: string, args?: unknown) { - setTimeout(() => - extensions - .determineExtensionFromCallStack() - .then((info) => { - sendTelemetryEvent(EventName.PYTHON_ENVIRONMENTS_API, undefined, { - apiName, - extensionId: info.extensionId, - }); - traceVerbose( - `Extension ${info.extensionId} accessed ${apiName} with args: ${JSON.stringify(args)}`, - ); - }) - .ignoreErrors(), - ); - } - disposables.push( - discoveryApi.onChanged((e) => { - const env = e.new ?? e.old; - if (!env || !filterUsingVSCodeContext(env)) { - // Filter out environments that are not in the current workspace. - return; - } - if (e.old) { - if (e.new) { - onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); - reportInterpretersChanged([ - { - path: getEnvPath(e.new.executable.filename, e.new.location).path, - type: 'update', - }, - ]); - } else { - onEnvironmentsChanged.fire({ type: 'remove', env: convertEnvInfoAndGetReference(e.old) }); - reportInterpretersChanged([ - { - path: getEnvPath(e.old.executable.filename, e.old.location).path, - type: 'remove', - }, - ]); - } - } else if (e.new) { - onEnvironmentsChanged.fire({ type: 'add', env: convertEnvInfoAndGetReference(e.new) }); - reportInterpretersChanged([ - { - path: getEnvPath(e.new.executable.filename, e.new.location).path, - type: 'add', - }, - ]); - } - }), - envVarsProvider.onDidEnvironmentVariablesChange((e) => { - onEnvironmentVariablesChanged.fire({ - resource: getWorkspaceFolder(e), - env: envVarsProvider.getEnvironmentVariablesSync(e), - }); - }), - onEnvironmentsChanged, - onEnvironmentVariablesChanged, - ); - /** - * @deprecated Will be removed soon. Use {@link ProposedExtensionAPI} instead. + * @deprecated Will be removed soon. */ let deprecatedProposedApi; try { @@ -199,197 +26,6 @@ export function buildProposedApi( const proposed: ProposedExtensionAPI & DeprecatedProposedAPI = { ...deprecatedProposedApi, - environments: { - getEnvironmentVariables: (resource?: Resource) => { - sendApiTelemetry('getEnvironmentVariables'); - resource = resource && 'uri' in resource ? resource.uri : resource; - return envVarsProvider.getEnvironmentVariablesSync(resource); - }, - get onDidEnvironmentVariablesChange() { - sendApiTelemetry('onDidEnvironmentVariablesChange'); - return onEnvironmentVariablesChanged.event; - }, - getActiveEnvironmentPath(resource?: Resource) { - sendApiTelemetry('getActiveEnvironmentPath'); - resource = resource && 'uri' in resource ? resource.uri : resource; - const path = configService.getSettings(resource).pythonPath; - const id = path === 'python' ? 'DEFAULT_PYTHON' : getEnvID(path); - return { - id, - path, - }; - }, - updateActiveEnvironmentPath( - env: Environment | EnvironmentPath | string, - resource?: Resource, - ): Promise { - sendApiTelemetry('updateActiveEnvironmentPath'); - const path = typeof env !== 'string' ? env.path : env; - resource = resource && 'uri' in resource ? resource.uri : resource; - return interpreterPathService.update(resource, ConfigurationTarget.WorkspaceFolder, path); - }, - get onDidChangeActiveEnvironmentPath() { - sendApiTelemetry('onDidChangeActiveEnvironmentPath'); - return onDidActiveInterpreterChangedEvent.event; - }, - resolveEnvironment: async (env: Environment | EnvironmentPath | string) => { - if (!workspace.isTrusted) { - throw new Error('Not allowed to resolve environment in an untrusted workspace'); - } - let path = typeof env !== 'string' ? env.path : env; - if (pathUtils.basename(path) === path) { - // Value can be `python`, `python3`, `python3.9` etc. - // This case could eventually be handled by the internal discovery API itself. - const pythonExecutionFactory = serviceContainer.get( - IPythonExecutionFactory, - ); - const pythonExecutionService = await pythonExecutionFactory.create({ pythonPath: path }); - const fullyQualifiedPath = await pythonExecutionService.getExecutablePath().catch((ex) => { - traceError('Cannot resolve full path', ex); - return undefined; - }); - // Python path is invalid or python isn't installed. - if (!fullyQualifiedPath) { - return undefined; - } - path = fullyQualifiedPath; - } - sendApiTelemetry('resolveEnvironment', env); - return resolveEnvironment(path, discoveryApi); - }, - get known(): Environment[] { - sendApiTelemetry('known'); - return discoveryApi - .getEnvs() - .filter((e) => filterUsingVSCodeContext(e)) - .map((e) => convertEnvInfoAndGetReference(e)); - }, - async refreshEnvironments(options?: RefreshOptions) { - if (!workspace.isTrusted) { - traceError('Not allowed to refresh environments in an untrusted workspace'); - return; - } - await discoveryApi.triggerRefresh(undefined, { - ifNotTriggerredAlready: !options?.forceRefresh, - }); - sendApiTelemetry('refreshEnvironments'); - }, - get onDidChangeEnvironments() { - sendApiTelemetry('onDidChangeEnvironments'); - return onEnvironmentsChanged.event; - }, - }, }; return proposed; } - -async function resolveEnvironment(path: string, discoveryApi: IDiscoveryAPI): Promise { - const env = await discoveryApi.resolveEnv(path); - if (!env) { - return undefined; - } - const resolvedEnv = getEnvReference(convertCompleteEnvInfo(env)) as ResolvedEnvironment; - if (resolvedEnv.version?.major === -1 || resolvedEnv.version?.minor === -1 || resolvedEnv.version?.micro === -1) { - traceError(`Invalid version for ${path}: ${JSON.stringify(env)}`); - } - return resolvedEnv; -} - -export function convertCompleteEnvInfo(env: PythonEnvInfo): ResolvedEnvironment { - const version = { ...env.version, sysVersion: env.version.sysVersion }; - let tool = convertKind(env.kind); - if (env.type && !tool) { - tool = 'Unknown'; - } - const { path } = getEnvPath(env.executable.filename, env.location); - const resolvedEnv: ResolvedEnvironment = { - path, - id: env.id!, - executable: { - uri: env.executable.filename === 'python' ? undefined : Uri.file(env.executable.filename), - bitness: convertBitness(env.arch), - sysPrefix: env.executable.sysPrefix, - }, - environment: env.type - ? { - type: convertEnvType(env.type), - name: env.name === '' ? undefined : env.name, - folderUri: Uri.file(env.location), - workspaceFolder: getWorkspaceFolder(env.searchLocation), - } - : undefined, - version: env.executable.filename === 'python' ? undefined : (version as ResolvedEnvironment['version']), - tools: tool ? [tool] : [], - }; - return resolvedEnv; -} - -function convertEnvType(envType: PythonEnvType): EnvironmentType { - if (envType === PythonEnvType.Conda) { - return 'Conda'; - } - if (envType === PythonEnvType.Virtual) { - return 'VirtualEnvironment'; - } - return 'Unknown'; -} - -function convertKind(kind: PythonEnvKind): EnvironmentTools | undefined { - switch (kind) { - case PythonEnvKind.Venv: - return 'Venv'; - case PythonEnvKind.Pipenv: - return 'Pipenv'; - case PythonEnvKind.Poetry: - return 'Poetry'; - case PythonEnvKind.VirtualEnvWrapper: - return 'VirtualEnvWrapper'; - case PythonEnvKind.VirtualEnv: - return 'VirtualEnv'; - case PythonEnvKind.Conda: - return 'Conda'; - case PythonEnvKind.Pyenv: - return 'Pyenv'; - default: - return undefined; - } -} - -export function convertEnvInfo(env: PythonEnvInfo): Environment { - const convertedEnv = convertCompleteEnvInfo(env) as Mutable; - if (convertedEnv.executable.sysPrefix === '') { - convertedEnv.executable.sysPrefix = undefined; - } - if (convertedEnv.version?.sysVersion === '') { - convertedEnv.version.sysVersion = undefined; - } - if (convertedEnv.version?.major === -1) { - convertedEnv.version.major = undefined; - } - if (convertedEnv.version?.micro === -1) { - convertedEnv.version.micro = undefined; - } - if (convertedEnv.version?.minor === -1) { - convertedEnv.version.minor = undefined; - } - return convertedEnv as Environment; -} - -function convertEnvInfoAndGetReference(env: PythonEnvInfo): Environment { - return getEnvReference(convertEnvInfo(env)); -} - -function convertBitness(arch: Architecture) { - switch (arch) { - case Architecture.x64: - return '64-bit'; - case Architecture.x86: - return '32-bit'; - default: - return 'Unknown'; - } -} - -function getEnvID(path: string) { - return normCasePath(path); -} diff --git a/extensions/positron-python/src/client/proposedApiTypes.ts b/extensions/positron-python/src/client/proposedApiTypes.ts index a40ad3312d02..1b772b406644 100644 --- a/extensions/positron-python/src/client/proposedApiTypes.ts +++ b/extensions/positron-python/src/client/proposedApiTypes.ts @@ -1,300 +1,4 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { CancellationToken, Event, Uri, WorkspaceFolder } from 'vscode'; - -// https://github.com/microsoft/vscode-python/wiki/Proposed-Environment-APIs - -export interface ProposedExtensionAPI { - readonly environments: { - /** - * Returns the environment configured by user in settings. Note that this can be an invalid environment, use - * {@link resolveEnvironment} to get full details. - * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root - * scenario. If `undefined`, then the API returns what ever is set for the workspace. - */ - getActiveEnvironmentPath(resource?: Resource): EnvironmentPath; - /** - * Sets the active environment path for the python extension for the resource. Configuration target will always - * be the workspace folder. - * @param environment : If string, it represents the full path to environment folder or python executable - * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. - * @param resource : [optional] File or workspace to scope to a particular workspace folder. - */ - updateActiveEnvironmentPath( - environment: string | EnvironmentPath | Environment, - resource?: Resource, - ): Promise; - /** - * This event is triggered when the active environment setting changes. - */ - readonly onDidChangeActiveEnvironmentPath: Event; - /** - * Carries environments known to the extension at the time of fetching the property. Note this may not - * contain all environments in the system as a refresh might be going on. - * - * Only reports environments in the current workspace. - */ - readonly known: readonly Environment[]; - /** - * This event is triggered when the known environment list changes, like when a environment - * is found, existing environment is removed, or some details changed on an environment. - */ - readonly onDidChangeEnvironments: Event; - /** - * This API will trigger environment discovery, but only if it has not already happened in this VSCode session. - * Useful for making sure env list is up-to-date when the caller needs it for the first time. - * - * To force trigger a refresh regardless of whether a refresh was already triggered, see option - * {@link RefreshOptions.forceRefresh}. - * - * Note that if there is a refresh already going on then this returns the promise for that refresh. - * @param options Additional options for refresh. - * @param token A cancellation token that indicates a refresh is no longer needed. - */ - refreshEnvironments(options?: RefreshOptions, token?: CancellationToken): Promise; - /** - * Returns details for the given environment, or `undefined` if the env is invalid. - * @param environment : If string, it represents the full path to environment folder or python executable - * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself. - */ - resolveEnvironment( - environment: Environment | EnvironmentPath | string, - ): Promise; - /** - * Returns the environment variables used by the extension for a resource, which includes the custom - * variables configured by user in `.env` files. - * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root - * scenario. If `undefined`, then the API returns what ever is set for the workspace. - */ - getEnvironmentVariables(resource?: Resource): EnvironmentVariables; - /** - * This event is fired when the environment variables for a resource change. Note it's currently not - * possible to detect if environment variables in the system change, so this only fires if custom - * environment variables are updated in `.env` files. - */ - readonly onDidEnvironmentVariablesChange: Event; - }; -} - -export type RefreshOptions = { - /** - * When `true`, force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so - * it's best to only use it if user manually triggers a refresh. - */ - forceRefresh?: boolean; -}; - -/** - * Details about the environment. Note the environment folder, type and name never changes over time. - */ -export type Environment = EnvironmentPath & { - /** - * Carries details about python executable. - */ - readonly executable: { - /** - * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to - * the environment. - */ - readonly uri: Uri | undefined; - /** - * Bitness if known at this moment. - */ - readonly bitness: Bitness | undefined; - /** - * Value of `sys.prefix` in sys module if known at this moment. - */ - readonly sysPrefix: string | undefined; - }; - /** - * Carries details if it is an environment, otherwise `undefined` in case of global interpreters and others. - */ - readonly environment: - | { - /** - * Type of the environment. - */ - readonly type: EnvironmentType; - /** - * Name to the environment if any. - */ - readonly name: string | undefined; - /** - * Uri of the environment folder. - */ - readonly folderUri: Uri; - /** - * Any specific workspace folder this environment is created for. - */ - readonly workspaceFolder: WorkspaceFolder | undefined; - } - | undefined; - /** - * Carries Python version information known at this moment, carries `undefined` for envs without python. - */ - readonly version: - | (VersionInfo & { - /** - * Value of `sys.version` in sys module if known at this moment. - */ - readonly sysVersion: string | undefined; - }) - | undefined; - /** - * Tools/plugins which created the environment or where it came from. First value in array corresponds - * to the primary tool which manages the environment, which never changes over time. - * - * Array is empty if no tool is responsible for creating/managing the environment. Usually the case for - * global interpreters. - */ - readonly tools: readonly EnvironmentTools[]; -}; - -/** - * Derived form of {@link Environment} where certain properties can no longer be `undefined`. Meant to represent an - * {@link Environment} with complete information. - */ -export type ResolvedEnvironment = Environment & { - /** - * Carries complete details about python executable. - */ - readonly executable: { - /** - * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to - * the environment. - */ - readonly uri: Uri | undefined; - /** - * Bitness of the environment. - */ - readonly bitness: Bitness; - /** - * Value of `sys.prefix` in sys module. - */ - readonly sysPrefix: string; - }; - /** - * Carries complete Python version information, carries `undefined` for envs without python. - */ - readonly version: - | (ResolvedVersionInfo & { - /** - * Value of `sys.version` in sys module if known at this moment. - */ - readonly sysVersion: string; - }) - | undefined; -}; - -export type EnvironmentsChangeEvent = { - readonly env: Environment; - /** - * * "add": New environment is added. - * * "remove": Existing environment in the list is removed. - * * "update": New information found about existing environment. - */ - readonly type: 'add' | 'remove' | 'update'; -}; - -export type ActiveEnvironmentPathChangeEvent = EnvironmentPath & { - /** - * Workspace folder the environment changed for. - */ - readonly resource: WorkspaceFolder | undefined; -}; - -/** - * Uri of a file inside a workspace or workspace folder itself. - */ -export type Resource = Uri | WorkspaceFolder; - -export type EnvironmentPath = { - /** - * The ID of the environment. - */ - readonly id: string; - /** - * Path to environment folder or path to python executable that uniquely identifies an environment. Environments - * lacking a python executable are identified by environment folder paths, whereas other envs can be identified - * using python executable path. - */ - readonly path: string; -}; - -/** - * Tool/plugin where the environment came from. It can be {@link KnownEnvironmentTools} or custom string which - * was contributed. - */ -export type EnvironmentTools = KnownEnvironmentTools | string; -/** - * Tools or plugins the Python extension currently has built-in support for. Note this list is expected to shrink - * once tools have their own separate extensions. - */ -export type KnownEnvironmentTools = - | 'Conda' - | 'Pipenv' - | 'Poetry' - | 'VirtualEnv' - | 'Venv' - | 'VirtualEnvWrapper' - | 'Pyenv' - | 'Unknown'; - -/** - * Type of the environment. It can be {@link KnownEnvironmentTypes} or custom string which was contributed. - */ -export type EnvironmentType = KnownEnvironmentTypes | string; -/** - * Environment types the Python extension is aware of. Note this list is expected to shrink once tools have their - * own separate extensions, in which case they're expected to provide the type themselves. - */ -export type KnownEnvironmentTypes = 'VirtualEnvironment' | 'Conda' | 'Unknown'; - -/** - * Carries bitness for an environment. - */ -export type Bitness = '64-bit' | '32-bit' | 'Unknown'; - -/** - * The possible Python release levels. - */ -export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; - -/** - * Release information for a Python version. - */ -export type PythonVersionRelease = { - readonly level: PythonReleaseLevel; - readonly serial: number; -}; - -export type VersionInfo = { - readonly major: number | undefined; - readonly minor: number | undefined; - readonly micro: number | undefined; - readonly release: PythonVersionRelease | undefined; -}; - -export type ResolvedVersionInfo = { - readonly major: number; - readonly minor: number; - readonly micro: number; - readonly release: PythonVersionRelease; -}; - -/** - * A record containing readonly keys. - */ -export type EnvironmentVariables = { readonly [key: string]: string | undefined }; - -export type EnvironmentVariablesChangeEvent = { - /** - * Workspace folder the environment variables changed for. - */ - readonly resource: WorkspaceFolder | undefined; - /** - * Updated value of environment variables. - */ - readonly env: EnvironmentVariables; -}; +export interface ProposedExtensionAPI {} diff --git a/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts b/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts new file mode 100644 index 000000000000..5e9ff7f818ef --- /dev/null +++ b/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri } from 'vscode'; +import { inject, injectable } from 'inversify'; +import { IDisposableRegistry } from '../../common/types'; +import { Common, ToolsExtensions } from '../../common/utils/localize'; +import { isExtensionEnabled } from '../../common/vscodeApis/extensionsApi'; +import { showInformationMessage } from '../../common/vscodeApis/windowApis'; +import { getConfiguration, onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; +import { IServiceContainer } from '../../ioc/types'; +import { + doNotShowPromptState, + inFormatterExtensionExperiment, + installFormatterExtension, + updateDefaultFormatter, +} from './promptUtils'; +import { AUTOPEP8_EXTENSION, BLACK_EXTENSION, IInstallFormatterPrompt } from './types'; + +const SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY = 'showFormatterExtensionInstallPrompt'; + +@injectable() +export class InstallFormatterPrompt implements IInstallFormatterPrompt { + private shownThisSession = false; + + constructor(@inject(IServiceContainer) private readonly serviceContainer: IServiceContainer) {} + + public async showInstallFormatterPrompt(resource?: Uri): Promise { + if (!inFormatterExtensionExperiment(this.serviceContainer)) { + return false; + } + + const promptState = doNotShowPromptState(SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY, this.serviceContainer); + if (this.shownThisSession || promptState.value) { + return false; + } + + const config = getConfiguration('python', resource); + const formatter = config.get('formatting.provider', 'none'); + if (!['autopep8', 'black'].includes(formatter)) { + return false; + } + + const editorConfig = getConfiguration('editor', { uri: resource, languageId: 'python' }); + const defaultFormatter = editorConfig.get('defaultFormatter', ''); + if ([BLACK_EXTENSION, AUTOPEP8_EXTENSION].includes(defaultFormatter)) { + return false; + } + + const black = isExtensionEnabled(BLACK_EXTENSION); + const autopep8 = isExtensionEnabled(AUTOPEP8_EXTENSION); + + let selection: string | undefined; + + if (black || autopep8) { + this.shownThisSession = true; + if (black && autopep8) { + selection = await showInformationMessage( + ToolsExtensions.selectMultipleFormattersPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ); + } else if (black) { + selection = await showInformationMessage( + ToolsExtensions.selectBlackFormatterPrompt, + Common.bannerLabelYes, + Common.doNotShowAgain, + ); + if (selection === Common.bannerLabelYes) { + selection = 'Black'; + } + } else if (autopep8) { + selection = await showInformationMessage( + ToolsExtensions.selectAutopep8FormatterPrompt, + Common.bannerLabelYes, + Common.doNotShowAgain, + ); + if (selection === Common.bannerLabelYes) { + selection = 'Autopep8'; + } + } + } else if (formatter === 'black' && !black) { + this.shownThisSession = true; + selection = await showInformationMessage( + ToolsExtensions.installBlackFormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ); + } else if (formatter === 'autopep8' && !autopep8) { + this.shownThisSession = true; + selection = await showInformationMessage( + ToolsExtensions.installAutopep8FormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ); + } + + if (selection === 'Black') { + if (black) { + await updateDefaultFormatter(BLACK_EXTENSION, resource); + } else { + await installFormatterExtension(BLACK_EXTENSION, resource); + } + } else if (selection === 'Autopep8') { + if (autopep8) { + await updateDefaultFormatter(AUTOPEP8_EXTENSION, resource); + } else { + await installFormatterExtension(AUTOPEP8_EXTENSION, resource); + } + } else if (selection === Common.doNotShowAgain) { + await promptState.updateValue(true); + } + + return this.shownThisSession; + } +} + +export function registerInstallFormatterPrompt(serviceContainer: IServiceContainer): void { + const disposables = serviceContainer.get(IDisposableRegistry); + const installFormatterPrompt = serviceContainer.get(IInstallFormatterPrompt); + disposables.push( + onDidSaveTextDocument(async (e) => { + const editorConfig = getConfiguration('editor', { uri: e.uri, languageId: 'python' }); + if (e.languageId === 'python' && editorConfig.get('formatOnSave')) { + await installFormatterPrompt.showInstallFormatterPrompt(e.uri); + } + }), + ); +} diff --git a/extensions/positron-python/src/client/providers/prompts/promptUtils.ts b/extensions/positron-python/src/client/providers/prompts/promptUtils.ts new file mode 100644 index 000000000000..05b1b28f061a --- /dev/null +++ b/extensions/positron-python/src/client/providers/prompts/promptUtils.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ConfigurationTarget, Uri } from 'vscode'; +import { ShowFormatterExtensionPrompt } from '../../common/experiments/groups'; +import { IExperimentService, IPersistentState, IPersistentStateFactory } from '../../common/types'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { isInsider } from '../../common/vscodeApis/extensionsApi'; +import { getConfiguration, getWorkspaceFolder } from '../../common/vscodeApis/workspaceApis'; +import { IServiceContainer } from '../../ioc/types'; + +export function inFormatterExtensionExperiment(serviceContainer: IServiceContainer): boolean { + const experiment = serviceContainer.get(IExperimentService); + return experiment.inExperimentSync(ShowFormatterExtensionPrompt.experiment); +} + +export function doNotShowPromptState(key: string, serviceContainer: IServiceContainer): IPersistentState { + const persistFactory = serviceContainer.get(IPersistentStateFactory); + const promptState = persistFactory.createWorkspacePersistentState(key, false); + return promptState; +} + +export async function updateDefaultFormatter(extensionId: string, resource?: Uri): Promise { + const scope = getWorkspaceFolder(resource) ? ConfigurationTarget.Workspace : ConfigurationTarget.Global; + + const config = getConfiguration('python', resource); + const editorConfig = getConfiguration('editor', { uri: resource, languageId: 'python' }); + await editorConfig.update('defaultFormatter', extensionId, scope, true); + await config.update('formatting.provider', 'none', scope); +} + +export async function installFormatterExtension(extensionId: string, resource?: Uri): Promise { + await executeCommand('workbench.extensions.installExtension', extensionId, { + installPreReleaseVersion: isInsider(), + }); + + await updateDefaultFormatter(extensionId, resource); +} diff --git a/extensions/positron-python/src/client/providers/prompts/types.ts b/extensions/positron-python/src/client/providers/prompts/types.ts new file mode 100644 index 000000000000..4edaadb46b46 --- /dev/null +++ b/extensions/positron-python/src/client/providers/prompts/types.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri } from 'vscode'; + +export const BLACK_EXTENSION = 'ms-python.black-formatter'; +export const AUTOPEP8_EXTENSION = 'ms-python.autopep8'; + +export const IInstallFormatterPrompt = Symbol('IInstallFormatterPrompt'); +export interface IInstallFormatterPrompt { + showInstallFormatterPrompt(resource?: Uri): Promise; +} diff --git a/extensions/positron-python/src/client/providers/serviceRegistry.ts b/extensions/positron-python/src/client/providers/serviceRegistry.ts index a96ec14ff5e9..70fc6dc34135 100644 --- a/extensions/positron-python/src/client/providers/serviceRegistry.ts +++ b/extensions/positron-python/src/client/providers/serviceRegistry.ts @@ -6,10 +6,13 @@ import { IExtensionSingleActivationService } from '../activation/types'; import { IServiceManager } from '../ioc/types'; import { CodeActionProviderService } from './codeActionProvider/main'; +import { InstallFormatterPrompt } from './prompts/installFormatterPrompt'; +import { IInstallFormatterPrompt } from './prompts/types'; export function registerTypes(serviceManager: IServiceManager): void { serviceManager.addSingleton( IExtensionSingleActivationService, CodeActionProviderService, ); + serviceManager.addSingleton(IInstallFormatterPrompt, InstallFormatterPrompt); } diff --git a/extensions/positron-python/src/client/providers/terminalProvider.ts b/extensions/positron-python/src/client/providers/terminalProvider.ts index ee1de62acd8c..d047ea4b6d82 100644 --- a/extensions/positron-python/src/client/providers/terminalProvider.ts +++ b/extensions/positron-python/src/client/providers/terminalProvider.ts @@ -4,8 +4,9 @@ import { Disposable, Terminal } from 'vscode'; import { IActiveResourceService, ICommandManager } from '../common/application/types'; import { Commands } from '../common/constants'; +import { inTerminalEnvVarExperiment } from '../common/experiments/helpers'; import { ITerminalActivator, ITerminalServiceFactory } from '../common/terminal/types'; -import { IConfigurationService } from '../common/types'; +import { IConfigurationService, IExperimentService } from '../common/types'; import { swallowExceptions } from '../common/utils/decorators'; import { IServiceContainer } from '../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../telemetry'; @@ -24,9 +25,14 @@ export class TerminalProvider implements Disposable { @swallowExceptions('Failed to initialize terminal provider') public async initialize(currentTerminal: Terminal | undefined): Promise { const configuration = this.serviceContainer.get(IConfigurationService); + const experimentService = this.serviceContainer.get(IExperimentService); const pythonSettings = configuration.getSettings(this.activeResourceService.getActiveResource()); - if (currentTerminal && pythonSettings.terminal.activateEnvInCurrentTerminal) { + if ( + currentTerminal && + pythonSettings.terminal.activateEnvInCurrentTerminal && + !inTerminalEnvVarExperiment(experimentService) + ) { const hideFromUser = 'hideFromUser' in currentTerminal.creationOptions && currentTerminal.creationOptions.hideFromUser; if (!hideFromUser) { diff --git a/extensions/positron-python/src/client/pylanceApi.ts b/extensions/positron-python/src/client/pylanceApi.ts new file mode 100644 index 000000000000..b839d0d9c2b7 --- /dev/null +++ b/extensions/positron-python/src/client/pylanceApi.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TelemetryEventMeasurements, TelemetryEventProperties } from '@vscode/extension-telemetry'; +import { BaseLanguageClient } from 'vscode-languageclient'; + +export interface TelemetryReporter { + sendTelemetryEvent( + eventName: string, + properties?: TelemetryEventProperties, + measurements?: TelemetryEventMeasurements, + ): void; + sendTelemetryErrorEvent( + eventName: string, + properties?: TelemetryEventProperties, + measurements?: TelemetryEventMeasurements, + ): void; +} + +export interface ApiForPylance { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createClient(...args: any[]): BaseLanguageClient; + start(client: BaseLanguageClient): Promise; + stop(client: BaseLanguageClient): Promise; + getTelemetryReporter(): TelemetryReporter; +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts b/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts index 180e243ae710..d3d3a252c99f 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts @@ -169,11 +169,16 @@ class EnvironmentInfoService implements IEnvironmentInfoService { return undefined; }); } else if (reason) { - if (reason.message.includes('Unknown option: -I')) { + if ( + reason.message.includes('Unknown option: -I') || + reason.message.includes("ModuleNotFoundError: No module named 'encodings'") + ) { traceWarn(reason); - traceError( - 'Support for Python 2.7 has been dropped by the Python extension so certain features may not work, upgrade to using Python 3.', - ); + if (reason.message.includes('Unknown option: -I')) { + traceError( + 'Support for Python 2.7 has been dropped by the Python extension so certain features may not work, upgrade to using Python 3.', + ); + } return buildEnvironmentInfo(env, false).catch((err) => { traceError(err); return undefined; diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts b/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts index f696bd40d173..5341a8f561ae 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { PythonExecutableInfo, PythonVersion } from '.'; +import { isCI } from '../../../common/constants'; import { interpreterInfo as getInterpreterInfoCommand, InterpreterInfoJson, @@ -80,13 +81,15 @@ export async function getInterpreterInfo( '', ); + // Sometimes on CI, the python process takes a long time to start up. This is a workaround for that. + const standardTimeout = isCI ? 30000 : 15000; // Try shell execing the command, followed by the arguments. This will make node kill the process if it // takes too long. // Sometimes the python path isn't valid, timeout if that's the case. // See these two bugs: // https://github.com/microsoft/vscode-python/issues/7569 // https://github.com/microsoft/vscode-python/issues/7760 - const result = await shellExecute(quoted, { timeout: timeout ?? 15000 }); + const result = await shellExecute(quoted, { timeout: timeout ?? standardTimeout }); if (result.stderr) { traceError( `Stderr when executing script with >> ${quoted} << stderr: ${result.stderr}, still attempting to parse output`, diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts index b8ae5bcf4cd2..d3ece41f163d 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts @@ -5,13 +5,14 @@ import { Event } from 'vscode'; import { isTestExecution } from '../../../../common/constants'; import { traceInfo, traceVerbose } from '../../../../logging'; import { arePathsSame, getFileInfo, pathExists } from '../../../common/externalDependencies'; -import { PythonEnvInfo } from '../../info'; +import { PythonEnvInfo, PythonEnvKind } from '../../info'; import { areEnvsDeepEqual, areSameEnv, getEnvPath } from '../../info/env'; import { BasicPythonEnvCollectionChangedEvent, PythonEnvCollectionChangedEvent, PythonEnvsWatcher, } from '../../watcher'; +import { getCondaInterpreterPath } from '../../../common/environmentManagers/conda'; export interface IEnvsCollectionCache { /** @@ -126,6 +127,7 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher { const env = this.envs.splice(index, 1)[0]; + traceVerbose(`Removing invalid env from cache ${env.id}`); this.fire({ old: env, new: undefined }); }); if (envs) { @@ -145,15 +147,18 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher areSameEnv(e, env)); + if (!found) { + this.envs.push(env); + this.fire({ new: env }); + } else if (hasLatestInfo && !this.validatedEnvs.has(env.id!)) { + // Update cache if we have latest info and the env is not already validated. + this.updateEnv(found, env, true); + } if (hasLatestInfo) { traceVerbose(`Flushing env to cache ${env.id}`); this.validatedEnvs.add(env.id!); this.flush(env).ignoreErrors(); // If we have latest info, flush it so it can be saved. } - if (!found) { - this.envs.push(env); - this.fire({ new: env }); - } } public updateEnv(oldValue: PythonEnvInfo, newValue: PythonEnvInfo | undefined, forceUpdate = false): void { @@ -176,6 +181,20 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher { // `path` can either be path to environment or executable path const env = this.envs.find((e) => arePathsSame(e.location, path)) ?? this.envs.find((e) => areSameEnv(e, path)); + if ( + env?.kind === PythonEnvKind.Conda && + getEnvPath(env.executable.filename, env.location).pathType === 'envFolderPath' + ) { + if (await pathExists(getCondaInterpreterPath(env.location))) { + // This is a conda env without python in cache which actually now has a valid python, so return + // `undefined` and delete value from cache as cached value is not the latest anymore. + this.validatedEnvs.delete(env.id!); + return undefined; + } + // Do not attempt to validate these envs as they lack an executable, and consider them as validated by default. + this.validatedEnvs.add(env.id!); + return env; + } if (env) { if (this.validatedEnvs.has(env.id!)) { traceVerbose(`Found cached env for ${path}`); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsReducer.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsReducer.ts index 2f9bcea8e590..49f5b619694e 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsReducer.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsReducer.ts @@ -129,6 +129,7 @@ function checkIfFinishedAndNotify( if (state.done && state.pending === 0) { didUpdate.fire({ stage: ProgressReportStage.discoveryFinished }); didUpdate.dispose(); + traceVerbose(`Finished with environment reducer`); } } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts index 4d68370ea262..2ba54e07ed9c 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsResolver.ts @@ -174,6 +174,7 @@ function checkIfFinishedAndNotify( if (state.done && state.pending === 0) { didUpdate.fire({ stage: ProgressReportStage.discoveryFinished }); didUpdate.dispose(); + traceVerbose(`Finished with environment resolver`); } } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activestateLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts similarity index 96% rename from extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activestateLocator.ts rename to extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts index 315f8e283d85..987abf4f4157 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activestateLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts @@ -40,5 +40,6 @@ export class ActiveStateLocator extends LazyResourceBasedLocator { } } } + traceVerbose(`Finished searching for active state environments`); } } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/condaLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/condaLocator.ts index a355a72d5336..7cac0cb7df90 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/condaLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/condaLocator.ts @@ -38,5 +38,6 @@ export class CondaEnvironmentLocator extends FSWatchingLocator { traceError(`Failed to process conda env: ${JSON.stringify(env)}`, ex); } } + traceVerbose(`Finished searching for conda environments`); } } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/customVirtualEnvLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/customVirtualEnvLocator.ts index 06cbfe38ee3c..57ae9187cdc2 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/customVirtualEnvLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/customVirtualEnvLocator.ts @@ -132,6 +132,7 @@ export class CustomVirtualEnvironmentLocator extends FSWatchingLocator { }); yield* iterable(chain(envGenerators)); + traceVerbose(`Finished searching for custom virtual envs`); } return iterator(); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/globalVirtualEnvronmentLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/globalVirtualEnvronmentLocator.ts index cddec23f7b0b..d86b2182d50c 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/globalVirtualEnvronmentLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/globalVirtualEnvronmentLocator.ts @@ -134,6 +134,7 @@ export class GlobalVirtualEnvironmentLocator extends FSWatchingLocator { }); yield* iterable(chain(envGenerators)); + traceVerbose(`Finished searching for global virtual envs`); } return iterator(); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts index 6b8e5ac0084c..9b5283f7f967 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts @@ -12,6 +12,7 @@ import { isStorePythonInstalled, getMicrosoftStoreAppsRoot, } from '../../../common/environmentManagers/microsoftStoreEnv'; +import { traceVerbose } from '../../../../logging'; /** * This is a glob pattern which matches following file names: @@ -91,6 +92,7 @@ export class MicrosoftStoreLocator extends FSWatchingLocator { kind, executablePath, })); + traceVerbose(`Finished searching for windows store envs`); }; return iterator(this.kind); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/poetryLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/poetryLocator.ts index 0b210c05b6a1..4084c7a5cfbc 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/poetryLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/poetryLocator.ts @@ -70,6 +70,7 @@ export class PoetryLocator extends LazyResourceBasedLocator { }); yield* iterable(chain(envGenerators)); + traceVerbose(`Finished searching for poetry envs`); } return iterator(this.root); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts index 25923701c05a..97726307c573 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts @@ -8,8 +8,8 @@ import { BasicEnvInfo, IPythonEnvsIterator, Locator } from '../../locator'; import { commonPosixBinPaths, getPythonBinFromPosixPaths } from '../../../common/posixUtils'; import { isPyenvShimDir } from '../../../common/environmentManagers/pyenv'; import { getOSType, OSType } from '../../../../common/utils/platform'; -import { isMacDefaultPythonPath } from './macDefaultLocator'; -import { traceError } from '../../../../logging'; +import { isMacDefaultPythonPath } from '../../../common/environmentManagers/macDefault'; +import { traceError, traceVerbose } from '../../../../logging'; export class PosixKnownPathsLocator extends Locator { public readonly providerId = 'posixKnownPaths'; @@ -44,6 +44,7 @@ export class PosixKnownPathsLocator extends Locator { traceError(`Failed to process environment: ${bin}`, ex); } } + traceVerbose('Finished searching for interpreters in posix paths locator'); }; return iterator(this.kind); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts index 89346069772d..dc3290c9993c 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts @@ -7,7 +7,7 @@ import { FSWatchingLocator } from './fsWatchingLocator'; import { getInterpreterPathFromDir } from '../../../common/commonUtils'; import { getSubDirs } from '../../../common/externalDependencies'; import { getPyenvVersionsDir } from '../../../common/environmentManagers/pyenv'; -import { traceError } from '../../../../logging'; +import { traceError, traceVerbose } from '../../../../logging'; /** * Gets all the pyenv environments. @@ -33,6 +33,7 @@ async function* getPyenvEnvironments(): AsyncIterableIterator { } } } + traceVerbose('Finished searching for pyenv environments'); } export class PyenvLocator extends FSWatchingLocator { diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts index b7cb27875769..377b1117b858 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts @@ -16,6 +16,7 @@ import { Locators } from '../../locators'; import { getEnvs } from '../../locatorUtils'; import { PythonEnvsChangedEvent } from '../../watcher'; import { DirFilesLocator } from './filesLocator'; +import { traceVerbose } from '../../../../logging'; /** * A locator for Windows locators found under the $PATH env var. @@ -93,6 +94,7 @@ function getDirFilesLocator( // take a naive approach. async function* iterEnvs(query: PythonLocatorQuery): IPythonEnvsIterator { yield* await getEnvs(locator.iterEnvs(query)); + traceVerbose('Finished searching for windows path interpreters'); } return { providerId: locator.providerId, diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts index d589231cc7ca..954d1bfd2a41 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts @@ -5,7 +5,7 @@ import { PythonEnvKind, PythonEnvSource } from '../../info'; import { BasicEnvInfo, IPythonEnvsIterator, Locator } from '../../locator'; import { getRegistryInterpreters } from '../../../common/windowsUtils'; -import { traceError } from '../../../../logging'; +import { traceError, traceVerbose } from '../../../../logging'; import { isMicrosoftStoreDir } from '../../../common/environmentManagers/microsoftStoreEnv'; export class WindowsRegistryLocator extends Locator { @@ -33,6 +33,7 @@ export class WindowsRegistryLocator extends Locator { traceError(`Failed to process environment: ${interpreter}`, ex); } } + traceVerbose('Finished searching for windows registry interpreters'); }; return iterator(); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts index 127515607563..b815e1d30a89 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts @@ -97,6 +97,7 @@ export class WorkspaceVirtualEnvironmentLocator extends FSWatchingLocator { }); yield* iterable(chain(envGenerators)); + traceVerbose(`Finished searching for workspace virtual envs`); } return iterator(this.root); diff --git a/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/conda.ts b/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/conda.ts index bb5720a15312..88178d02d58a 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/conda.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/conda.ts @@ -22,6 +22,7 @@ import { isTestExecution } from '../../../common/constants'; import { traceError, traceVerbose } from '../../../logging'; import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; import { splitLines } from '../../../common/stringUtils'; +import { SpawnOptions } from '../../../common/process/types'; export const AnacondaCompanyName = 'Anaconda, Inc.'; export const CONDAPATH_SETTING_KEY = 'condaPath'; @@ -248,9 +249,9 @@ export class Conda { * need a Conda instance should use getConda() to obtain it, and should never access * this property directly. */ - private static condaPromise: Promise | undefined; + private static condaPromise = new Map>(); - private condaInfoCached: Promise | undefined; + private condaInfoCached = new Map | undefined>(); /** * Carries path to conda binary to be used for shell execution. @@ -263,18 +264,18 @@ export class Conda { * @param command - Command used to spawn conda. This has the same meaning as the * first argument of spawn() - i.e. it can be a full path, or just a binary name. */ - constructor(readonly command: string, shellCommand?: string) { + constructor(readonly command: string, shellCommand?: string, private readonly shellPath?: string) { this.shellCommand = shellCommand ?? command; onDidChangePythonSetting(CONDAPATH_SETTING_KEY, () => { - Conda.condaPromise = undefined; + Conda.condaPromise = new Map>(); }); } - public static async getConda(): Promise { - if (Conda.condaPromise === undefined || isTestExecution()) { - Conda.condaPromise = Conda.locate(); + public static async getConda(shellPath?: string): Promise { + if (Conda.condaPromise.get(shellPath) === undefined || isTestExecution()) { + Conda.condaPromise.set(shellPath, Conda.locate(shellPath)); } - return Conda.condaPromise; + return Conda.condaPromise.get(shellPath); } /** @@ -283,7 +284,7 @@ export class Conda { * * @return A Conda instance corresponding to the binary, if successful; otherwise, undefined. */ - private static async locate(): Promise { + private static async locate(shellPath?: string): Promise { traceVerbose(`Searching for conda.`); const home = getUserHomeDir(); const customCondaPath = getPythonSetting(CONDAPATH_SETTING_KEY); @@ -383,7 +384,7 @@ export class Conda { // Probe the candidates, and pick the first one that exists and does what we need. for await (const condaPath of getCandidates()) { traceVerbose(`Probing conda binary: ${condaPath}`); - let conda = new Conda(condaPath); + let conda = new Conda(condaPath, undefined, shellPath); try { await conda.getInfo(); if (getOSType() === OSType.Windows && (isTestExecution() || condaPath !== customCondaPath)) { @@ -392,9 +393,9 @@ export class Conda { const condaBatFile = await getCondaBatFile(condaPath); try { if (condaBatFile) { - const condaBat = new Conda(condaBatFile); + const condaBat = new Conda(condaBatFile, undefined, shellPath); await condaBat.getInfo(); - conda = new Conda(condaPath, condaBatFile); + conda = new Conda(condaPath, condaBatFile, shellPath); } } catch (ex) { traceVerbose('Failed to spawn conda bat file', condaBatFile, ex); @@ -420,10 +421,12 @@ export class Conda { * Corresponds to "conda info --json". */ public async getInfo(useCache?: boolean): Promise { - if (!useCache || !this.condaInfoCached) { - this.condaInfoCached = this.getInfoImpl(this.command); + let condaInfoCached = this.condaInfoCached.get(this.shellPath); + if (!useCache || !condaInfoCached) { + condaInfoCached = this.getInfoImpl(this.command, this.shellPath); + this.condaInfoCached.set(this.shellPath, condaInfoCached); } - return this.condaInfoCached; + return condaInfoCached; } /** @@ -431,8 +434,12 @@ export class Conda { */ @cache(30_000, true, 10_000) // eslint-disable-next-line class-methods-use-this - private async getInfoImpl(command: string): Promise { - const result = await exec(command, ['info', '--json'], { timeout: CONDA_GENERAL_TIMEOUT }); + private async getInfoImpl(command: string, shellPath: string | undefined): Promise { + const options: SpawnOptions = { timeout: CONDA_GENERAL_TIMEOUT }; + if (shellPath) { + options.shell = shellPath; + } + const result = await exec(command, ['info', '--json'], options); traceVerbose(`${command} info --json: ${result.stdout}`); return JSON.parse(result.stdout); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/macDefault.ts similarity index 67% rename from extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.ts rename to extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/macDefault.ts index 9baf6d2affd3..931fbbba9eac 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/macDefault.ts @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { getOSType, OSType } from '../../../../common/utils/platform'; - -// TODO: Add tests for 'isMacDefaultPythonPath' when working on the locator +import { getOSType, OSType } from '../../../common/utils/platform'; /** * Decide if the given Python executable looks like the MacOS default Python. @@ -13,7 +11,7 @@ export function isMacDefaultPythonPath(pythonPath: string): boolean { return false; } - const defaultPaths = ['python', '/usr/bin/python']; + const defaultPaths = ['/usr/bin/python']; return defaultPaths.includes(pythonPath) || pythonPath.startsWith('/usr/bin/python2'); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts index b05670feae02..cfbf81909d59 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -9,7 +9,12 @@ import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; import { getCreationEvents, handleCreateEnvironmentCommand } from './createEnvironment'; import { condaCreationProvider } from './provider/condaCreationProvider'; import { VenvCreationProvider } from './provider/venvCreationProvider'; -import { CreateEnvironmentOptions, CreateEnvironmentProvider, CreateEnvironmentResult } from './types'; +import { + CreateEnvironmentExitedEventArgs, + CreateEnvironmentOptions, + CreateEnvironmentProvider, + CreateEnvironmentResult, +} from './types'; import { showInformationMessage } from '../../common/vscodeApis/windowApis'; import { CreateEnv } from '../../common/utils/localize'; @@ -62,10 +67,10 @@ export function registerCreateEnvironmentFeatures( disposables.push(registerCreateEnvironmentProvider(new VenvCreationProvider(interpreterQuickPick))); disposables.push(registerCreateEnvironmentProvider(condaCreationProvider())); disposables.push( - onCreateEnvironmentExited(async (e: CreateEnvironmentResult | undefined) => { - if (e && e.path) { - await interpreterPathService.update(e.uri, ConfigurationTarget.WorkspaceFolder, e.path); - showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.path)}`); + onCreateEnvironmentExited(async (e: CreateEnvironmentExitedEventArgs) => { + if (e.result?.path && e.options?.selectEnvironment) { + await interpreterPathService.update(e.result.uri, ConfigurationTarget.WorkspaceFolder, e.result.path); + showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.result.path)}`); } }), ); diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts index 2f21d787d336..bdeaf89ba82d 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -10,10 +10,16 @@ import { showQuickPickWithBack, } from '../../common/vscodeApis/windowApis'; import { traceError, traceVerbose } from '../../logging'; -import { CreateEnvironmentOptions, CreateEnvironmentProvider, CreateEnvironmentResult } from './types'; +import { + CreateEnvironmentExitedEventArgs, + CreateEnvironmentOptions, + CreateEnvironmentProvider, + CreateEnvironmentResult, + CreateEnvironmentStartedEventArgs, +} from './types'; -const onCreateEnvironmentStartedEvent = new EventEmitter(); -const onCreateEnvironmentExitedEvent = new EventEmitter(); +const onCreateEnvironmentStartedEvent = new EventEmitter(); +const onCreateEnvironmentExitedEvent = new EventEmitter(); let startedEventCount = 0; @@ -21,19 +27,19 @@ function isBusyCreatingEnvironment(): boolean { return startedEventCount > 0; } -function fireStartedEvent(): void { - onCreateEnvironmentStartedEvent.fire(); +function fireStartedEvent(options?: CreateEnvironmentOptions): void { + onCreateEnvironmentStartedEvent.fire({ options }); startedEventCount += 1; } -function fireExitedEvent(result: CreateEnvironmentResult | undefined): void { - onCreateEnvironmentExitedEvent.fire(result); +function fireExitedEvent(result?: CreateEnvironmentResult, options?: CreateEnvironmentOptions, error?: unknown): void { + onCreateEnvironmentExitedEvent.fire({ result, options, error }); startedEventCount -= 1; } export function getCreationEvents(): { - onCreateEnvironmentStarted: Event; - onCreateEnvironmentExited: Event; + onCreateEnvironmentStarted: Event; + onCreateEnvironmentExited: Event; isCreatingEnvironment: () => boolean; } { return { @@ -45,14 +51,12 @@ export function getCreationEvents(): { async function createEnvironment( provider: CreateEnvironmentProvider, - options: CreateEnvironmentOptions = { - ignoreSourceControl: true, - installPackages: true, - }, + options: CreateEnvironmentOptions, ): Promise { let result: CreateEnvironmentResult | undefined; + let err: unknown | undefined; try { - fireStartedEvent(); + fireStartedEvent(options); result = await provider.createEnvironment(options); } catch (ex) { if (ex === QuickInputButtons.Back) { @@ -61,9 +65,10 @@ async function createEnvironment( return undefined; } } - throw ex; + err = ex; + throw err; } finally { - fireExitedEvent(result); + fireExitedEvent(result, options, err); } return result; } @@ -110,17 +115,28 @@ async function showCreateEnvironmentQuickPick( return undefined; } +function getOptionsWithDefaults(options?: CreateEnvironmentOptions): CreateEnvironmentOptions { + return { + installPackages: true, + ignoreSourceControl: true, + showBackButton: false, + selectEnvironment: true, + ...options, + }; +} + export async function handleCreateEnvironmentCommand( providers: readonly CreateEnvironmentProvider[], options?: CreateEnvironmentOptions, ): Promise { + const optionsWithDefaults = getOptionsWithDefaults(options); let selectedProvider: CreateEnvironmentProvider | undefined; const envTypeStep = new MultiStepNode( undefined, async (context?: MultiStepAction) => { if (providers.length > 0) { try { - selectedProvider = await showCreateEnvironmentQuickPick(providers, options); + selectedProvider = await showCreateEnvironmentQuickPick(providers, optionsWithDefaults); } catch (ex) { if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { return ex; @@ -152,7 +168,7 @@ export async function handleCreateEnvironmentCommand( } if (selectedProvider) { try { - result = await createEnvironment(selectedProvider, options); + result = await createEnvironment(selectedProvider, optionsWithDefaults); } catch (ex) { if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { return ex; diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts index 28046cbc73ad..5bf032f9f65f 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -192,6 +192,10 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise { packages.push({ installType: 'requirements', installItem: i }); }); + } else { + return MultiStepAction.Cancel; } } catch (ex) { if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts new file mode 100644 index 000000000000..5ead37b80dc9 --- /dev/null +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TextDocument, TextDocumentChangeEvent } from 'vscode'; +import { IDisposableRegistry } from '../../common/types'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { + onDidOpenTextDocument, + onDidChangeTextDocument, + getOpenTextDocuments, +} from '../../common/vscodeApis/workspaceApis'; +import { isPipInstallableToml } from './provider/venvUtils'; + +async function setPyProjectTomlContextKey(doc: TextDocument): Promise { + if (isPipInstallableToml(doc.getText())) { + await executeCommand('setContext', 'pipInstallableToml', true); + } else { + await executeCommand('setContext', 'pipInstallableToml', false); + } +} + +export function registerPyProjectTomlCreateEnvFeatures(disposables: IDisposableRegistry): void { + disposables.push( + onDidOpenTextDocument(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }), + onDidChangeTextDocument(async (e: TextDocumentChangeEvent) => { + if (e.document.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(e.document); + } + }), + ); + + getOpenTextDocuments().forEach(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }); +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts index c18249a2bd72..6dbd8adfe1f4 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts @@ -6,9 +6,26 @@ import { Progress, Uri } from 'vscode'; export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {} export interface CreateEnvironmentOptions { + /** + * Default `true`. If `true`, the environment creation handler is expected to install packages. + */ installPackages?: boolean; + + /** + * Default `true`. If `true`, the environment creation provider is expected to add the environment to ignore list + * for the source control. + */ ignoreSourceControl?: boolean; + + /** + * Default `false`. If `true` the creation provider should show back button when showing QuickPick or QuickInput. + */ showBackButton?: boolean; + + /** + * Default `true`. If `true`, the environment will be selected as the environment to be used for the workspace. + */ + selectEnvironment?: boolean; } export interface CreateEnvironmentResult { @@ -17,6 +34,16 @@ export interface CreateEnvironmentResult { action?: 'Back' | 'Cancel'; } +export interface CreateEnvironmentStartedEventArgs { + options: CreateEnvironmentOptions | undefined; +} + +export interface CreateEnvironmentExitedEventArgs { + result: CreateEnvironmentResult | undefined; + error?: unknown; + options: CreateEnvironmentOptions | undefined; +} + export interface CreateEnvironmentProvider { createEnvironment(options?: CreateEnvironmentOptions): Promise; name: string; diff --git a/extensions/positron-python/src/client/pythonEnvironments/index.ts b/extensions/positron-python/src/client/pythonEnvironments/index.ts index 8d6a8cb7d4ba..8065811a8a62 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/index.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/index.ts @@ -36,7 +36,7 @@ import { import { EnvsCollectionService } from './base/locators/composite/envsCollectionService'; import { IDisposable } from '../common/types'; import { traceError } from '../logging'; -import { ActiveStateLocator } from './base/locators/lowLevel/activestateLocator'; +import { ActiveStateLocator } from './base/locators/lowLevel/activeStateLocator'; /** * Set up the Python environments component (during extension activation).' diff --git a/extensions/positron-python/src/client/pythonEnvironments/info/executable.ts b/extensions/positron-python/src/client/pythonEnvironments/info/executable.ts index 9b16d04f5753..70c74329c49b 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/info/executable.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/info/executable.ts @@ -14,11 +14,7 @@ import { copyPythonExecInfo, PythonExecInfo } from '../exec'; * @param python - the information to use when running Python * @param shellExec - the function to use to run Python */ -export async function getExecutablePath( - python: PythonExecInfo, - shellExec: ShellExecFunc, - timeout?: number, -): Promise { +export async function getExecutablePath(python: PythonExecInfo, shellExec: ShellExecFunc): Promise { try { const [args, parse] = getExecutable(); const info = copyPythonExecInfo(python, args); @@ -28,7 +24,7 @@ export async function getExecutablePath( (p, c) => (p ? `${p} ${c.toCommandArgumentForPythonExt()}` : `${c.toCommandArgumentForPythonExt()}`), '', ); - const result = await shellExec(quoted, { timeout: timeout ?? 15000 }); + const result = await shellExec(quoted, { timeout: 15000 }); const executable = parse(result.stdout.trim()); if (executable === '') { throw new Error(`${quoted} resulted in empty stdout`); diff --git a/extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts b/extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts index ce9bfb4caf11..0c80c3414728 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts @@ -10,7 +10,7 @@ import { IComponentAdapter, ICondaService, PythonEnvironmentsChangedEvent } from import { IServiceManager } from '../ioc/types'; import { PythonEnvInfo, PythonEnvKind, PythonEnvSource } from './base/info'; import { IDiscoveryAPI, PythonLocatorQuery, TriggerRefreshOptions } from './base/locator'; -import { isMacDefaultPythonPath } from './base/locators/lowLevel/macDefaultLocator'; +import { isMacDefaultPythonPath } from './common/environmentManagers/macDefault'; import { isParentPath } from './common/externalDependencies'; import { EnvironmentType, PythonEnvironment } from './info'; import { toSemverLikeVersion } from './base/info/pythonVersion'; diff --git a/extensions/positron-python/src/client/telemetry/constants.ts b/extensions/positron-python/src/client/telemetry/constants.ts index 4a895ab8a9ff..d30a2683562c 100644 --- a/extensions/positron-python/src/client/telemetry/constants.ts +++ b/extensions/positron-python/src/client/telemetry/constants.ts @@ -71,6 +71,7 @@ export enum EventName { LANGUAGE_SERVER_READY = 'LANGUAGE_SERVER.READY', LANGUAGE_SERVER_TELEMETRY = 'LANGUAGE_SERVER.EVENT', LANGUAGE_SERVER_REQUEST = 'LANGUAGE_SERVER.REQUEST', + LANGUAGE_SERVER_RESTART = 'LANGUAGE_SERVER.RESTART', TERMINAL_CREATE = 'TERMINAL.CREATE', ACTIVATE_ENV_IN_CURRENT_TERMINAL = 'ACTIVATE_ENV_IN_CURRENT_TERMINAL', diff --git a/extensions/positron-python/src/client/telemetry/index.ts b/extensions/positron-python/src/client/telemetry/index.ts index 41cf311c4d44..973e55302635 100644 --- a/extensions/positron-python/src/client/telemetry/index.ts +++ b/extensions/positron-python/src/client/telemetry/index.ts @@ -76,7 +76,7 @@ export function _resetSharedProperties(): void { } let telemetryReporter: TelemetryReporter | undefined; -function getTelemetryReporter() { +export function getTelemetryReporter(): TelemetryReporter { if (!isTestExecution() && telemetryReporter) { return telemetryReporter; } @@ -1475,6 +1475,17 @@ export interface IEventNamePropertyMapping { } */ [EventName.LANGUAGE_SERVER_REQUEST]: unknown; + /** + * Telemetry send when Language Server is restarted. + */ + /* __GDPR__ + "language_server_restart" : { + "reason" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karthiknadig" } + } + */ + [EventName.LANGUAGE_SERVER_RESTART]: { + reason: 'command' | 'settings' | 'notebooksExperiment'; + }; /** * Telemetry event sent when Jedi Language Server is started for workspace (workspace folder in case of multi-root) */ @@ -2043,7 +2054,7 @@ export interface IEventNamePropertyMapping { */ [EventName.ENVIRONMENT_INSTALLING_PACKAGES]: { environmentType: 'venv' | 'conda'; - using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml'; + using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml' | 'pipUpgrade'; }; /** * Telemetry event sent after installing packages. @@ -2056,7 +2067,7 @@ export interface IEventNamePropertyMapping { */ [EventName.ENVIRONMENT_INSTALLED_PACKAGES]: { environmentType: 'venv' | 'conda'; - using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml'; + using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml' | 'pipUpgrade'; }; /** * Telemetry event sent if installing packages failed. diff --git a/extensions/positron-python/src/client/telemetry/pylance.ts b/extensions/positron-python/src/client/telemetry/pylance.ts index 5fae11fe4a47..9348b0f39d03 100644 --- a/extensions/positron-python/src/client/telemetry/pylance.ts +++ b/extensions/positron-python/src/client/telemetry/pylance.ts @@ -20,7 +20,8 @@ "errorname" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" }, "errorstack" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth" }, "lsversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "method" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + "method" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "modulehash" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ /* __GDPR__ @@ -300,20 +301,25 @@ */ /* __GDPR__ "language_server/settings" : { + "addimportexactmatchonly" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "autoimportcompletions" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "autosearchpaths" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "completefunctionparens" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "disableworkspacesymbol" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "enableextractcodeaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "extracommitchars" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "formatontype" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "functionReturnInlayTypeHints" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "hasconfigfile" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "hasextrapaths" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "importformat" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "includeusersymbolsinautoimport" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "indexing" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "lspinteractivewindows" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "lspnotebooks" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "movesymbol" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "openfilesonly" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "pytestparameterinlaytypehints" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "typecheckingmode" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "useimportheuristic" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "uselibrarycodefortypes" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, diff --git a/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts b/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts index 8d16f0c0d9c9..d4781f7e03e5 100644 --- a/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts +++ b/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts @@ -6,7 +6,6 @@ import { WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { DocumentFilter } from 'vscode-languageclient/node'; import { NodeLanguageServerAnalysisOptions } from '../../../client/activation/node/analysisOptions'; -import { LspNotebooksExperiment } from '../../../client/activation/node/lspNotebooksExperiment'; import { ILanguageServerOutputChannel } from '../../../client/activation/types'; import { IWorkspaceService } from '../../../client/common/application/types'; import { PYTHON, PYTHON_LANGUAGE } from '../../../client/common/constants'; @@ -33,7 +32,6 @@ suite('Pylance Language Server - Analysis Options', () => { let lsOutputChannel: typemoq.IMock; let workspace: typemoq.IMock; let experimentService: IExperimentService; - let lspNotebooksExperiment: typemoq.IMock; setup(() => { outputChannel = typemoq.Mock.ofType().object; @@ -45,14 +43,7 @@ suite('Pylance Language Server - Analysis Options', () => { lsOutputChannel = typemoq.Mock.ofType(); lsOutputChannel.setup((l) => l.channel).returns(() => outputChannel); experimentService = typemoq.Mock.ofType().object; - lspNotebooksExperiment = typemoq.Mock.ofType(); - lspNotebooksExperiment.setup((l) => l.isInNotebooksExperiment()).returns(() => false); - analysisOptions = new TestClass( - lsOutputChannel.object, - workspace.object, - experimentService, - lspNotebooksExperiment.object, - ); + analysisOptions = new TestClass(lsOutputChannel.object, workspace.object, experimentService); }); test('Workspace folder is undefined', () => { diff --git a/extensions/positron-python/src/test/api.functional.test.ts b/extensions/positron-python/src/test/api.functional.test.ts index 490b5d86b8b3..74293f55256c 100644 --- a/extensions/positron-python/src/test/api.functional.test.ts +++ b/extensions/positron-python/src/test/api.functional.test.ts @@ -6,18 +6,17 @@ import { assert, expect } from 'chai'; import * as path from 'path'; import { instance, mock, when } from 'ts-mockito'; -import * as Typemoq from 'typemoq'; -import { Event, Uri } from 'vscode'; import { buildApi } from '../client/api'; import { ConfigurationService } from '../client/common/configuration/service'; import { EXTENSION_ROOT_DIR } from '../client/common/constants'; -import { IConfigurationService } from '../client/common/types'; +import { IConfigurationService, IDisposableRegistry } from '../client/common/types'; import { IEnvironmentVariablesProvider } from '../client/common/variables/types'; import { IInterpreterService } from '../client/interpreter/contracts'; import { InterpreterService } from '../client/interpreter/interpreterService'; import { ServiceContainer } from '../client/ioc/container'; import { ServiceManager } from '../client/ioc/serviceManager'; import { IServiceContainer, IServiceManager } from '../client/ioc/types'; +import { IDiscoveryAPI } from '../client/pythonEnvironments/base/locator'; suite('Extension API', () => { const debuggerPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'lib', 'python', 'debugpy'); @@ -28,6 +27,7 @@ suite('Extension API', () => { let serviceManager: IServiceManager; let configurationService: IConfigurationService; let interpreterService: IInterpreterService; + let discoverAPI: IDiscoveryAPI; let environmentVariablesProvider: IEnvironmentVariablesProvider; setup(() => { @@ -36,6 +36,7 @@ suite('Extension API', () => { configurationService = mock(ConfigurationService); interpreterService = mock(InterpreterService); environmentVariablesProvider = mock(); + discoverAPI = mock(); when(serviceContainer.get(IConfigurationService)).thenReturn( instance(configurationService), @@ -44,42 +45,7 @@ suite('Extension API', () => { instance(environmentVariablesProvider), ); when(serviceContainer.get(IInterpreterService)).thenReturn(instance(interpreterService)); - }); - - test('Execution details settings API returns expected object if interpreter is set', async () => { - const resource = Uri.parse('a'); - when(configurationService.getSettings(resource)).thenReturn({ pythonPath: 'settingValue' } as any); - - const execDetails = buildApi( - Promise.resolve(), - instance(serviceManager), - instance(serviceContainer), - ).settings.getExecutionDetails(resource); - - assert.deepEqual(execDetails, { execCommand: ['settingValue'] }); - }); - - test('Execution details settings API returns `undefined` if interpreter is set', async () => { - const resource = Uri.parse('a'); - when(configurationService.getSettings(resource)).thenReturn({ pythonPath: '' } as any); - - const execDetails = buildApi( - Promise.resolve(), - instance(serviceManager), - instance(serviceContainer), - ).settings.getExecutionDetails(resource); - - assert.deepEqual(execDetails, { execCommand: undefined }); - }); - - test('Provide a callback which is called when interpreter setting changes', async () => { - const expectedEvent = Typemoq.Mock.ofType>().object; - when(interpreterService.onDidChangeInterpreterConfiguration).thenReturn(expectedEvent); - - const result = buildApi(Promise.resolve(), instance(serviceManager), instance(serviceContainer)).settings - .onDidChangeExecutionDetails; - - assert.deepEqual(result, expectedEvent); + when(serviceContainer.get(IDisposableRegistry)).thenReturn([]); }); test('Test debug launcher args (no-wait)', async () => { @@ -89,6 +55,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getRemoteLauncherCommand(debuggerHost, debuggerPort, waitForAttach); const expectedArgs = [ debuggerPath.fileToCommandArgumentForPythonExt(), @@ -106,6 +73,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getRemoteLauncherCommand(debuggerHost, debuggerPort, waitForAttach); const expectedArgs = [ debuggerPath.fileToCommandArgumentForPythonExt(), @@ -122,6 +90,7 @@ suite('Extension API', () => { Promise.resolve(), instance(serviceManager), instance(serviceContainer), + instance(discoverAPI), ).debug.getDebuggerPackagePath(); assert.strictEqual(pkgPath, debuggerPath); diff --git a/extensions/positron-python/src/test/application/diagnostics/checks/pythonInterpreter.unit.test.ts b/extensions/positron-python/src/test/application/diagnostics/checks/pythonInterpreter.unit.test.ts index ea9bc9ae62d5..2397743274c1 100644 --- a/extensions/positron-python/src/test/application/diagnostics/checks/pythonInterpreter.unit.test.ts +++ b/extensions/positron-python/src/test/application/diagnostics/checks/pythonInterpreter.unit.test.ts @@ -9,6 +9,7 @@ import { EventEmitter, Uri } from 'vscode'; import { BaseDiagnosticsService } from '../../../../client/application/diagnostics/base'; import { InvalidLaunchJsonDebuggerDiagnostic } from '../../../../client/application/diagnostics/checks/invalidLaunchJsonDebugger'; import { + DefaultShellDiagnostic, InvalidPythonInterpreterDiagnostic, InvalidPythonInterpreterService, } from '../../../../client/application/diagnostics/checks/pythonInterpreter'; @@ -27,13 +28,21 @@ import { import { CommandsWithoutArgs } from '../../../../client/common/application/commands'; import { ICommandManager, IWorkspaceService } from '../../../../client/common/application/types'; import { Commands } from '../../../../client/common/constants'; -import { IPlatformService } from '../../../../client/common/platform/types'; -import { IDisposable, IDisposableRegistry, IInterpreterPathService, Resource } from '../../../../client/common/types'; +import { IFileSystem, IPlatformService } from '../../../../client/common/platform/types'; +import { IProcessService, IProcessServiceFactory } from '../../../../client/common/process/types'; +import { + IConfigurationService, + IDisposable, + IDisposableRegistry, + IInterpreterPathService, + Resource, +} from '../../../../client/common/types'; import { Common } from '../../../../client/common/utils/localize'; import { noop } from '../../../../client/common/utils/misc'; -import { IInterpreterHelper, IInterpreterService } from '../../../../client/interpreter/contracts'; +import { IInterpreterService } from '../../../../client/interpreter/contracts'; import { IServiceContainer } from '../../../../client/ioc/types'; import { EnvironmentType, PythonEnvironment } from '../../../../client/pythonEnvironments/info'; +import { getOSType, OSType } from '../../../common'; import { sleep } from '../../../core'; suite('Application Diagnostics - Checks Python Interpreter', () => { @@ -44,13 +53,28 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { let platformService: typemoq.IMock; let workspaceService: typemoq.IMock; let commandManager: typemoq.IMock; - let helper: typemoq.IMock; + let configService: typemoq.IMock; + let fs: typemoq.IMock; let serviceContainer: typemoq.IMock; + let processService: typemoq.IMock; let interpreterPathService: typemoq.IMock; + const oldComSpec = process.env.ComSpec; + const oldPath = process.env.Path; function createContainer() { + fs = typemoq.Mock.ofType(); + fs.setup((f) => f.fileExists(process.env.ComSpec ?? 'exists')).returns(() => Promise.resolve(true)); serviceContainer = typemoq.Mock.ofType(); + processService = typemoq.Mock.ofType(); + const processServiceFactory = typemoq.Mock.ofType(); + processServiceFactory.setup((p) => p.create()).returns(() => Promise.resolve(processService.object)); + serviceContainer + .setup((s) => s.get(typemoq.It.isValue(IProcessServiceFactory))) + .returns(() => processServiceFactory.object); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + processService.setup((p) => (p as any).then).returns(() => undefined); workspaceService = typemoq.Mock.ofType(); commandManager = typemoq.Mock.ofType(); + serviceContainer.setup((s) => s.get(typemoq.It.isValue(IFileSystem))).returns(() => fs.object); serviceContainer.setup((s) => s.get(typemoq.It.isValue(ICommandManager))).returns(() => commandManager.object); workspaceService.setup((w) => w.workspaceFile).returns(() => undefined); serviceContainer @@ -82,8 +106,11 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { serviceContainer .setup((s) => s.get(typemoq.It.isValue(IInterpreterPathService))) .returns(() => interpreterPathService.object); - helper = typemoq.Mock.ofType(); - serviceContainer.setup((s) => s.get(typemoq.It.isValue(IInterpreterHelper))).returns(() => helper.object); + configService = typemoq.Mock.ofType(); + configService.setup((c) => c.getSettings()).returns(() => ({ pythonPath: 'pythonPath' } as any)); + serviceContainer + .setup((s) => s.get(typemoq.It.isValue(IConfigurationService))) + .returns(() => configService.object); serviceContainer.setup((s) => s.get(typemoq.It.isValue(IDisposableRegistry))).returns(() => []); return serviceContainer.object; } @@ -102,6 +129,11 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { (diagnosticService as any)._clear(); }); + teardown(() => { + process.env.ComSpec = oldComSpec; + process.env.Path = oldPath; + }); + test('Registers command to trigger environment prompts', async () => { let triggerFunction: ((resource: Resource) => Promise) | undefined; commandManager @@ -191,7 +223,97 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { 'not the same', ); }); - test('Should return invalid diagnostics if there are interpreters but no current interpreter', async () => { + test('Should return comspec diagnostics if comspec is configured incorrectly', async function () { + if (getOSType() !== OSType.Windows) { + return this.skip(); + } + // No interpreter should exist if comspec is incorrectly configured. + interpreterService + .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) + .returns(() => { + return Promise.resolve(undefined); + }); + // Should fail with this error code if comspec is incorrectly configured. + processService + .setup((p) => p.shellExec(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.reject({ errno: -4058 })); + // Should be set to an invalid value in this case. + process.env.ComSpec = 'doesNotExist'; + fs.setup((f) => f.fileExists('doesNotExist')).returns(() => Promise.resolve(false)); + + const diagnostics = await diagnosticService._manualDiagnose(undefined); + expect(diagnostics).to.be.deep.equal( + [new DefaultShellDiagnostic(DiagnosticCodes.InvalidComspecDiagnostic, undefined)], + 'not the same', + ); + }); + test('Should return incomplete path diagnostics if `Path` variable is incomplete and execution fails', async function () { + if (getOSType() !== OSType.Windows) { + return this.skip(); + } + // No interpreter should exist if execution is failing. + interpreterService + .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) + .returns(() => { + return Promise.resolve(undefined); + }); + processService + .setup((p) => p.shellExec(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.reject({ errno: -4058 })); + process.env.Path = 'SystemRootDoesNotExist'; + const diagnostics = await diagnosticService._manualDiagnose(undefined); + expect(diagnostics).to.be.deep.equal( + [new DefaultShellDiagnostic(DiagnosticCodes.IncompletePathVarDiagnostic, undefined)], + 'not the same', + ); + }); + test('Should return default shell error diagnostic if execution fails but we do not identify the cause', async function () { + if (getOSType() !== OSType.Windows) { + return this.skip(); + } + // No interpreter should exist if execution is failing. + interpreterService + .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) + .returns(() => { + return Promise.resolve(undefined); + }); + processService + .setup((p) => p.shellExec(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.reject({ errno: -4058 })); + process.env.Path = 'C:\\Windows\\System32'; + const diagnostics = await diagnosticService._manualDiagnose(undefined); + expect(diagnostics).to.be.deep.equal( + [new DefaultShellDiagnostic(DiagnosticCodes.DefaultShellErrorDiagnostic, undefined)], + 'not the same', + ); + }); + test('Should return invalid interpreter diagnostics on non-Windows if there is no current interpreter and execution fails', async function () { + if (getOSType() === OSType.Windows) { + return this.skip(); + } + interpreterService.setup((i) => i.hasInterpreters()).returns(() => Promise.resolve(false)); + // No interpreter should exist if execution is failing. + interpreterService + .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) + .returns(() => { + return Promise.resolve(undefined); + }); + processService + .setup((p) => p.shellExec(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.reject({ errno: -4058 })); + const diagnostics = await diagnosticService._manualDiagnose(undefined); + expect(diagnostics).to.be.deep.equal( + [ + new InvalidPythonInterpreterDiagnostic( + DiagnosticCodes.InvalidPythonInterpreterDiagnostic, + undefined, + workspaceService.object, + ), + ], + 'not the same', + ); + }); + test('Should return invalid interpreter diagnostics if there are interpreters but no current interpreter', async () => { interpreterService .setup((i) => i.hasInterpreters()) .returns(() => Promise.resolve(true)) @@ -200,8 +322,7 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) .returns(() => { return Promise.resolve(undefined); - }) - .verifiable(typemoq.Times.once()); + }); const diagnostics = await diagnosticService._manualDiagnose(undefined); expect(diagnostics).to.be.deep.equal( @@ -214,24 +335,124 @@ suite('Application Diagnostics - Checks Python Interpreter', () => { ], 'not the same', ); - interpreterService.verifyAll(); }); test('Should return empty diagnostics if there are interpreters and a current interpreter', async () => { - interpreterService - .setup((i) => i.hasInterpreters()) - .returns(() => Promise.resolve(true)) - .verifiable(typemoq.Times.once()); + interpreterService.setup((i) => i.hasInterpreters()).returns(() => Promise.resolve(true)); interpreterService .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) .returns(() => { return Promise.resolve({ envType: EnvironmentType.Unknown } as any); - }) - .verifiable(typemoq.Times.once()); + }); const diagnostics = await diagnosticService._manualDiagnose(undefined); expect(diagnostics).to.be.deep.equal([], 'not the same'); - interpreterService.verifyAll(); }); + + test('Handling comspec diagnostic should launch expected browser link', async () => { + const diagnostic = new DefaultShellDiagnostic(DiagnosticCodes.InvalidComspecDiagnostic, undefined); + const cmd = ({} as any) as IDiagnosticCommand; + let messagePrompt: MessageCommandPrompt | undefined; + messageHandler + .setup((i) => i.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, p: MessageCommandPrompt) => (messagePrompt = p)) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'launch', + options: 'https://aka.ms/AAk3djo', + }), + ), + ) + .returns(() => cmd) + .verifiable(typemoq.Times.once()); + + await diagnosticService.handle([diagnostic]); + + messageHandler.verifyAll(); + commandFactory.verifyAll(); + expect(messagePrompt).not.be.equal(undefined, 'Message prompt not set'); + expect(messagePrompt!.commandPrompts).to.be.deep.equal([ + { + prompt: Common.seeInstructions, + command: cmd, + }, + ]); + }); + + test('Handling incomplete path diagnostic should launch expected browser link', async () => { + const diagnostic = new DefaultShellDiagnostic(DiagnosticCodes.IncompletePathVarDiagnostic, undefined); + const cmd = ({} as any) as IDiagnosticCommand; + let messagePrompt: MessageCommandPrompt | undefined; + messageHandler + .setup((i) => i.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, p: MessageCommandPrompt) => (messagePrompt = p)) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'launch', + options: 'https://aka.ms/AAk744c', + }), + ), + ) + .returns(() => cmd) + .verifiable(typemoq.Times.once()); + + await diagnosticService.handle([diagnostic]); + + messageHandler.verifyAll(); + commandFactory.verifyAll(); + expect(messagePrompt).not.be.equal(undefined, 'Message prompt not set'); + expect(messagePrompt!.commandPrompts).to.be.deep.equal([ + { + prompt: Common.seeInstructions, + command: cmd, + }, + ]); + }); + + test('Handling default shell error diagnostic should launch expected browser link', async () => { + const diagnostic = new DefaultShellDiagnostic(DiagnosticCodes.DefaultShellErrorDiagnostic, undefined); + const cmd = ({} as any) as IDiagnosticCommand; + let messagePrompt: MessageCommandPrompt | undefined; + messageHandler + .setup((i) => i.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, p: MessageCommandPrompt) => (messagePrompt = p)) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'launch', + options: 'https://aka.ms/AAk7qix', + }), + ), + ) + .returns(() => cmd) + .verifiable(typemoq.Times.once()); + + await diagnosticService.handle([diagnostic]); + + messageHandler.verifyAll(); + commandFactory.verifyAll(); + expect(messagePrompt).not.be.equal(undefined, 'Message prompt not set'); + expect(messagePrompt!.commandPrompts).to.be.deep.equal([ + { + prompt: Common.seeInstructions, + command: cmd, + }, + ]); + }); + test('Handling no interpreters diagnostic should return select interpreter cmd', async () => { const diagnostic = new InvalidPythonInterpreterDiagnostic( DiagnosticCodes.NoPythonInterpretersDiagnostic, diff --git a/extensions/positron-python/src/test/common/installer.test.ts b/extensions/positron-python/src/test/common/installer.test.ts index 6c1a6383c2b6..daea3e44ec76 100644 --- a/extensions/positron-python/src/test/common/installer.test.ts +++ b/extensions/positron-python/src/test/common/installer.test.ts @@ -42,8 +42,6 @@ import { } from '../../client/common/installer/types'; import { InterpreterPathService } from '../../client/common/interpreterPathService'; import { BrowserService } from '../../client/common/net/browser'; -import { FileDownloader } from '../../client/common/net/fileDownloader'; -import { HttpClient } from '../../client/common/net/httpClient'; import { PersistentStateFactory } from '../../client/common/persistentState'; import { PathUtils } from '../../client/common/platform/pathUtils'; import { CurrentProcess } from '../../client/common/process/currentProcess'; @@ -78,8 +76,6 @@ import { IEditorUtils, IExperimentService, IExtensions, - IFileDownloader, - IHttpClient, IInstaller, IInterpreterPathService, IPathUtils, @@ -199,8 +195,6 @@ suite('Installer', () => { ioc.serviceManager.addSingleton(IDebugService, DebugService); ioc.serviceManager.addSingleton(IApplicationEnvironment, ApplicationEnvironment); ioc.serviceManager.addSingleton(IBrowserService, BrowserService); - ioc.serviceManager.addSingleton(IHttpClient, HttpClient); - ioc.serviceManager.addSingleton(IFileDownloader, FileDownloader); ioc.serviceManager.addSingleton(IEditorUtils, EditorUtils); ioc.serviceManager.addSingleton(ITerminalActivator, TerminalActivator); ioc.serviceManager.addSingleton( diff --git a/extensions/positron-python/src/test/common/moduleInstaller.test.ts b/extensions/positron-python/src/test/common/moduleInstaller.test.ts index 302587902c16..fe4937f363ec 100644 --- a/extensions/positron-python/src/test/common/moduleInstaller.test.ts +++ b/extensions/positron-python/src/test/common/moduleInstaller.test.ts @@ -38,8 +38,6 @@ import { ProductInstaller } from '../../client/common/installer/productInstaller import { IModuleInstaller } from '../../client/common/installer/types'; import { InterpreterPathService } from '../../client/common/interpreterPathService'; import { BrowserService } from '../../client/common/net/browser'; -import { FileDownloader } from '../../client/common/net/fileDownloader'; -import { HttpClient } from '../../client/common/net/httpClient'; import { PersistentStateFactory } from '../../client/common/persistentState'; import { FileSystem } from '../../client/common/platform/fileSystem'; import { PathUtils } from '../../client/common/platform/pathUtils'; @@ -77,8 +75,6 @@ import { IEditorUtils, IExperimentService, IExtensions, - IFileDownloader, - IHttpClient, IInstaller, IInterpreterPathService, IPathUtils, @@ -211,8 +207,6 @@ suite('Module Installer', () => { JupyterExtensionDependencyManager, ); ioc.serviceManager.addSingleton(IBrowserService, BrowserService); - ioc.serviceManager.addSingleton(IHttpClient, HttpClient); - ioc.serviceManager.addSingleton(IFileDownloader, FileDownloader); ioc.serviceManager.addSingleton(IEditorUtils, EditorUtils); ioc.serviceManager.addSingleton(ITerminalActivator, TerminalActivator); ioc.serviceManager.addSingleton( diff --git a/extensions/positron-python/src/test/common/net/fileDownloader.unit.test.ts b/extensions/positron-python/src/test/common/net/fileDownloader.unit.test.ts deleted file mode 100644 index 515ed488dc2e..000000000000 --- a/extensions/positron-python/src/test/common/net/fileDownloader.unit.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import * as assert from 'assert'; -import { expect } from 'chai'; -import * as fsExtra from 'fs-extra'; -import * as nock from 'nock'; -import * as path from 'path'; -import rewiremock from 'rewiremock'; -import * as sinon from 'sinon'; -import { Readable, Writable } from 'stream'; -import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { Progress } from 'vscode'; -import { ApplicationShell } from '../../../client/common/application/applicationShell'; -import { IApplicationShell } from '../../../client/common/application/types'; -import { FileDownloader } from '../../../client/common/net/fileDownloader'; -import { HttpClient } from '../../../client/common/net/httpClient'; -import { FileSystem } from '../../../client/common/platform/fileSystem'; -import { PlatformService } from '../../../client/common/platform/platformService'; -import { IFileSystem } from '../../../client/common/platform/types'; -import { IHttpClient } from '../../../client/common/types'; -import { EXTENSION_ROOT_DIR } from '../../../client/constants'; -import * as logging from '../../../client/logging'; -import { noop } from '../../core'; - -const requestProgress = require('request-progress'); -const request = require('request'); - -type ProgressReporterData = { message?: string; increment?: number }; - -/** - * Writable stream that'll throw an error when written to. - * (used to mimick errors thrown when writing to a file). - * - * @class ErroringMemoryStream - * @extends {Writable} - */ -class ErroringMemoryStream extends Writable { - constructor(private readonly errorMessage: string) { - super(); - } - public _write(_chunk: any, _encoding: any, callback: any) { - super.emit('error', new Error(this.errorMessage)); - return callback(); - } -} -/** - * Readable stream that's slow to return data. - * (used to mimic slow file downloads). - * - * @class DelayedReadMemoryStream - * @extends {Readable} - */ -class DelayedReadMemoryStream extends Readable { - private readCounter = 0; - constructor( - private readonly totalKb: number, - private readonly delayMs: number, - private readonly kbPerIteration: number, - ) { - super(); - } - // @ts-ignore https://devblogs.microsoft.com/typescript/announcing-typescript-4-0-rc/#properties-overridding-accessors-and-vice-versa-is-an-error - public get readableLength() { - return 1024 * 10; - } - public _read() { - // Delay reading data, mimicking slow file downloads. - setTimeout(() => this.sendMessage(), this.delayMs); - } - public sendMessage() { - const i = (this.readCounter += 1); - if (i > this.totalKb / this.kbPerIteration) { - this.push(null); - } else { - this.push(Buffer.from('a'.repeat(this.kbPerIteration), 'ascii')); - } - } -} - -suite('File Downloader', () => { - let fileDownloader: FileDownloader; - let httpClient: IHttpClient; - let fs: IFileSystem; - let appShell: IApplicationShell; - suiteTeardown(() => { - rewiremock.disable(); - sinon.restore(); - }); - suite('File Downloader (real)', () => { - const uri = 'https://python.extension/package.json'; - const packageJsonFile = path.join(EXTENSION_ROOT_DIR, 'package.json'); - setup(() => { - rewiremock.disable(); - httpClient = mock(HttpClient); - appShell = mock(ApplicationShell); - when(httpClient.downloadFile(anything())).thenCall(request); - fs = new FileSystem(); - }); - teardown(() => { - rewiremock.disable(); - sinon.restore(); - }); - test('File gets downloaded', async () => { - // When downloading a uri, point it to package.json file. - nock('https://python.extension') - .get('/package.json') - .reply(200, () => fsExtra.createReadStream(packageJsonFile)); - const progressReportStub = sinon.stub(); - const progressReporter: Progress = { report: progressReportStub }; - const tmpFilePath = await fs.createTemporaryFile('.json'); - when(appShell.withProgressCustomIcon(anything(), anything())).thenCall((_, cb) => cb(progressReporter)); - - fileDownloader = new FileDownloader(instance(httpClient), fs, instance(appShell)); - await fileDownloader.downloadFileWithStatusBarProgress(uri, 'hello', tmpFilePath.filePath); - - // Confirm the package.json file gets downloaded - const expectedFileContents = fsExtra.readFileSync(packageJsonFile).toString(); - assert.strictEqual(fsExtra.readFileSync(tmpFilePath.filePath).toString(), expectedFileContents); - }); - test('Error is throw for http Status !== 200', async () => { - // When downloading a uri, throw status 500 error. - nock('https://python.extension').get('/package.json').reply(500); - const progressReportStub = sinon.stub(); - const progressReporter: Progress = { report: progressReportStub }; - when(appShell.withProgressCustomIcon(anything(), anything())).thenCall((_, cb) => cb(progressReporter)); - const tmpFilePath = await fs.createTemporaryFile('.json'); - - fileDownloader = new FileDownloader(instance(httpClient), fs, instance(appShell)); - const promise = fileDownloader.downloadFileWithStatusBarProgress(uri, 'hello', tmpFilePath.filePath); - - await expect(promise).to.eventually.be.rejectedWith( - 'Failed with status 500, null, Uri https://python.extension/package.json', - ); - }); - test('Error is throw if unable to write to the file stream', async () => { - // When downloading a uri, point it to package.json file. - nock('https://python.extension') - .get('/package.json') - .reply(200, () => fsExtra.createReadStream(packageJsonFile)); - const progressReportStub = sinon.stub(); - const progressReporter: Progress = { report: progressReportStub }; - when(appShell.withProgressCustomIcon(anything(), anything())).thenCall((_, cb) => cb(progressReporter)); - - // Use bogus files that cannot be created (on windows, invalid drives, on mac & linux use invalid home directories). - const invalidFileName = new PlatformService().isWindows - ? 'abcd:/bogusFile/one.txt' - : '/bogus file path/.txt'; - fileDownloader = new FileDownloader(instance(httpClient), fs, instance(appShell)); - const promise = fileDownloader.downloadFileWithStatusBarProgress(uri, 'hello', invalidFileName); - - // Things should fall over. - await expect(promise).to.eventually.be.rejected; - }); - test('Error is throw if file stream throws an error', async () => { - // When downloading a uri, point it to package.json file. - nock('https://python.extension') - .get('/package.json') - .reply(200, () => fsExtra.createReadStream(packageJsonFile)); - const progressReportStub = sinon.stub(); - const progressReporter: Progress = { report: progressReportStub }; - when(appShell.withProgressCustomIcon(anything(), anything())).thenCall((_, cb) => cb(progressReporter)); - // Create a file stream that will throw an error when written to (use ErroringMemoryStream). - const tmpFilePath = 'bogus file'; - const fileSystem = mock(FileSystem); - const fileStream = new ErroringMemoryStream('kaboom from fs'); - when(fileSystem.createWriteStream(tmpFilePath)).thenReturn(fileStream as any); - - fileDownloader = new FileDownloader(instance(httpClient), instance(fileSystem), instance(appShell)); - const promise = fileDownloader.downloadFileWithStatusBarProgress(uri, 'hello', tmpFilePath); - - // Confirm error from FS is bubbled up. - await expect(promise).to.eventually.be.rejectedWith('kaboom from fs'); - }); - test('Report progress as file gets downloaded', async () => { - const totalKb = 50; - // When downloading a uri, point it to stream that's slow. - // We'll return data from this stream slowly, mimicking a slow download. - // When the download is slow, we can test progress. - nock('https://python.extension') - .get('/package.json') - .reply(200, () => [ - 200, - new DelayedReadMemoryStream(1024 * totalKb, 5, 1024 * 10), - { 'content-length': 1024 * totalKb }, - ]); - const progressReportStub = sinon.stub(); - const progressReporter: Progress = { report: progressReportStub }; - when(appShell.withProgressCustomIcon(anything(), anything())).thenCall((_, cb) => cb(progressReporter)); - const tmpFilePath = await fs.createTemporaryFile('.json'); - // Mock request-progress to throttle 1ms, so we can get progress messages. - // I.e. report progress every 1ms. (however since download is delayed to 10ms, - // we'll get progress reported every 10ms. We use 1ms, to ensure its guaranteed - // to be reported. Else changing it to 10ms could result in it being reported in 12ms - rewiremock.enable(); - rewiremock('request-progress').with((reqUri: string) => requestProgress(reqUri, { throttle: 1 })); - - fileDownloader = new FileDownloader(instance(httpClient), fs, instance(appShell)); - await fileDownloader.downloadFileWithStatusBarProgress(uri, 'Downloading-something', tmpFilePath.filePath); - - // Since we are throttling the progress notifications for ever 1ms, - // and we're delaying downloading by every 10ms, we'll have progress reported for every 10ms. - // So we'll have progress reported for every 10kb of data downloaded, for a total of 5 times. - expect(progressReportStub.callCount).to.equal(5); - expect(progressReportStub.args[0][0].message).to.equal(getProgressMessage(10, 20)); - expect(progressReportStub.args[1][0].message).to.equal(getProgressMessage(20, 40)); - expect(progressReportStub.args[2][0].message).to.equal(getProgressMessage(30, 60)); - expect(progressReportStub.args[3][0].message).to.equal(getProgressMessage(40, 80)); - expect(progressReportStub.args[4][0].message).to.equal(getProgressMessage(50, 100)); - - function getProgressMessage(downloadedKb: number, percentage: number) { - return `Downloading-something${downloadedKb.toFixed()} of ${totalKb.toFixed()} KB (${percentage.toString()}%)`; - } - }); - }); - suite('File Downloader (mocks)', () => { - let downloadWithProgressStub: sinon.SinonStub; - let traceLogStub: sinon.SinonStub; - setup(() => { - traceLogStub = sinon.stub(logging, 'traceLog'); - httpClient = mock(HttpClient); - fs = mock(FileSystem); - appShell = mock(ApplicationShell); - downloadWithProgressStub = sinon.stub(FileDownloader.prototype, 'displayDownloadProgress'); - downloadWithProgressStub.callsFake(() => Promise.resolve()); - }); - teardown(() => { - sinon.restore(); - }); - test('Create temporary file and return path to that file', async () => { - const tmpFile = { filePath: 'my temp file', dispose: noop }; - when(fs.createTemporaryFile('.pdf')).thenResolve(tmpFile); - fileDownloader = new FileDownloader(instance(httpClient), instance(fs), instance(appShell)); - - const file = await fileDownloader.downloadFile('file', { progressMessagePrefix: '', extension: '.pdf' }); - - verify(fs.createTemporaryFile('.pdf')).once(); - assert.strictEqual(file, 'my temp file'); - }); - test('Display progress message in output channel', async () => { - const tmpFile = { filePath: 'my temp file', dispose: noop }; - when(fs.createTemporaryFile('.pdf')).thenResolve(tmpFile); - fileDownloader = new FileDownloader(instance(httpClient), instance(fs), instance(appShell)); - - await fileDownloader.downloadFile('file to download', { - progressMessagePrefix: '', - extension: '.pdf', - }); - - traceLogStub.calledWithExactly('Downloading file to download...'); - }); - test('Display progress when downloading', async () => { - const tmpFile = { filePath: 'my temp file', dispose: noop }; - when(fs.createTemporaryFile('.pdf')).thenResolve(tmpFile); - const statusBarProgressStub = sinon.stub(FileDownloader.prototype, 'downloadFileWithStatusBarProgress'); - statusBarProgressStub.callsFake(() => Promise.resolve()); - fileDownloader = new FileDownloader(instance(httpClient), instance(fs), instance(appShell)); - - await fileDownloader.downloadFile('file', { progressMessagePrefix: '', extension: '.pdf' }); - - assert.ok(statusBarProgressStub.calledOnce); - }); - test('Dispose temp file and bubble error thrown by status progress', async () => { - const disposeStub = sinon.stub(); - const tmpFile = { filePath: 'my temp file', dispose: disposeStub }; - when(fs.createTemporaryFile('.pdf')).thenResolve(tmpFile); - const statusBarProgressStub = sinon.stub(FileDownloader.prototype, 'downloadFileWithStatusBarProgress'); - statusBarProgressStub.callsFake(() => Promise.reject(new Error('kaboom'))); - fileDownloader = new FileDownloader(instance(httpClient), instance(fs), instance(appShell)); - - const promise = fileDownloader.downloadFile('file', { progressMessagePrefix: '', extension: '.pdf' }); - - await expect(promise).to.eventually.be.rejectedWith('kaboom'); - assert.ok(statusBarProgressStub.calledOnce); - assert.ok(disposeStub.calledOnce); - }); - }); -}); diff --git a/extensions/positron-python/src/test/common/net/httpClient.unit.test.ts b/extensions/positron-python/src/test/common/net/httpClient.unit.test.ts deleted file mode 100644 index 9679b8eb71d1..000000000000 --- a/extensions/positron-python/src/test/common/net/httpClient.unit.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import * as assert from 'assert'; -import { expect } from 'chai'; - -import rewiremock from 'rewiremock'; -import * as TypeMoq from 'typemoq'; -import { WorkspaceConfiguration } from 'vscode'; -import { IWorkspaceService } from '../../../client/common/application/types'; -import { HttpClient } from '../../../client/common/net/httpClient'; -import { IServiceContainer } from '../../../client/ioc/types'; - -suite('Http Client', () => { - const proxy = 'https://myproxy.net:4242'; - let config: TypeMoq.IMock; - let workSpaceService: TypeMoq.IMock; - let container: TypeMoq.IMock; - let httpClient: HttpClient; - setup(() => { - container = TypeMoq.Mock.ofType(); - workSpaceService = TypeMoq.Mock.ofType(); - config = TypeMoq.Mock.ofType(); - config - .setup((c) => c.get(TypeMoq.It.isValue('proxy'), TypeMoq.It.isValue(''))) - .returns(() => proxy) - .verifiable(TypeMoq.Times.once()); - workSpaceService - .setup((w) => w.getConfiguration(TypeMoq.It.isValue('http'))) - .returns(() => config.object) - .verifiable(TypeMoq.Times.once()); - container.setup((a) => a.get(TypeMoq.It.isValue(IWorkspaceService))).returns(() => workSpaceService.object); - - httpClient = new HttpClient(container.object); - }); - test('Get proxy info', async () => { - expect(httpClient.requestOptions).to.deep.equal({ proxy: proxy }); - config.verifyAll(); - workSpaceService.verifyAll(); - }); - suite('Test getJSON()', async () => { - teardown(() => { - rewiremock.disable(); - }); - [ - { - name: 'Throw error if request returns with download error', - returnedArgs: ['downloadError', { statusCode: 201 }, undefined], - expectedErrorMessage: 'downloadError', - }, - { - name: 'Throw error if request does not return with status code 200', - returnedArgs: [undefined, { statusCode: 201, statusMessage: 'wrongStatus' }, undefined], - expectedErrorMessage: 'Failed with status 201, wrongStatus, Uri downloadUri', - }, - { - name: 'If strict is set to true, and parsing fails, throw error', - returnedArgs: [undefined, { statusCode: 200 }, '[{ "strictJSON" : true,, }]'], - strict: true, - }, - ].forEach(async (testParams) => { - test(testParams.name, async () => { - const requestMock = (_uri: any, _requestOptions: any, callBackFn: Function) => - callBackFn(...testParams.returnedArgs); - rewiremock.enable(); - rewiremock('request').with(requestMock); - let rejected = true; - try { - await httpClient.getJSON('downloadUri', testParams.strict); - rejected = false; - } catch (ex) { - if (testParams.expectedErrorMessage) { - const error = ex as Error; - // Compare error messages - if (error.message) { - ex = error.message; - } - expect(ex).to.equal( - testParams.expectedErrorMessage, - 'Promise rejected with the wrong error message', - ); - } - } - assert(rejected === true, 'Promise should be rejected'); - }); - }); - - [ - { - name: - "If strict is set to false, and jsonc parsing returns error codes, then log errors and don't throw, return json", - returnedArgs: [undefined, { statusCode: 200 }, '[{ "strictJSON" : false,, }]'], - strict: false, - expectedJSON: [{ strictJSON: false }], - }, - { - name: 'Return expected json if strict is set to true and parsing is successful', - returnedArgs: [undefined, { statusCode: 200 }, '[{ "strictJSON" : true }]'], - strict: true, - expectedJSON: [{ strictJSON: true }], - }, - { - name: 'Return expected json if strict is set to false and parsing is successful', - returnedArgs: [undefined, { statusCode: 200 }, '[{ //Comment \n "strictJSON" : false }]'], - strict: false, - expectedJSON: [{ strictJSON: false }], - }, - ].forEach(async (testParams) => { - test(testParams.name, async () => { - const requestMock = (_uri: any, _requestOptions: any, callBackFn: Function) => - callBackFn(...testParams.returnedArgs); - rewiremock.enable(); - rewiremock('request').with(requestMock); - let json; - try { - json = await httpClient.getJSON('downloadUri', testParams.strict); - } catch (ex) { - assert(false, 'Promise should not be rejected'); - } - assert.deepEqual(json, testParams.expectedJSON, 'Unexpected JSON returned'); - }); - }); - }); -}); diff --git a/extensions/positron-python/src/test/common/process/logger.unit.test.ts b/extensions/positron-python/src/test/common/process/logger.unit.test.ts index 3b0fc239e183..ebce120b7e6c 100644 --- a/extensions/positron-python/src/test/common/process/logger.unit.test.ts +++ b/extensions/positron-python/src/test/common/process/logger.unit.test.ts @@ -11,7 +11,6 @@ import untildify = require('untildify'); import { WorkspaceFolder } from 'vscode'; import { IWorkspaceService } from '../../../client/common/application/types'; import { ProcessLogger } from '../../../client/common/process/logger'; -import { Logging } from '../../../client/common/utils/localize'; import { getOSType, OSType } from '../../../client/common/utils/platform'; import * as logging from '../../../client/logging'; @@ -41,7 +40,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar'], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger adds quotes around arguments if they contain spaces', async () => { @@ -49,10 +48,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar', 'import test'], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar "import test"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} ${path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${path.join('debug', 'path')}`); }); test('Logger preserves quotes around arguments if they contain spaces', async () => { @@ -60,10 +56,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar', '"import test"'], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar "import test"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} ${path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${path.join('debug', 'path')}`); }); test('Logger converts single quotes around arguments to double quotes if they contain spaces', async () => { @@ -71,10 +64,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar', "'import test'"], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar "import test"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} ${path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${path.join('debug', 'path')}`); }); test('Logger removes single quotes around arguments if they do not contain spaces', async () => { @@ -82,10 +72,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar', "'importtest'"], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar importtest`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} ${path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${path.join('debug', 'path')}`); }); test('Logger replaces the path/to/home with ~ in the current working directory', async () => { @@ -93,10 +80,7 @@ suite('ProcessLogger suite', () => { logger.logProcess('test', ['--foo', '--bar'], options); sinon.assert.calledWithExactly(traceLogStub, `> test --foo --bar`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} ${path.join('~', 'debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${path.join('~', 'debug', 'path')}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS at the beginning of the path', async () => { @@ -104,7 +88,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(path.join(untildify('~'), 'test'), ['--foo', '--bar'], options); sinon.assert.calledWithExactly(traceLogStub, `> ${path.join('~', 'test')} --foo --bar`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS at the beginning of the path but another arg contains other ref to home folder', async () => { @@ -112,7 +96,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(path.join(untildify('~'), 'test'), ['--foo', path.join(untildify('~'), 'boo')], options); sinon.assert.calledWithExactly(traceLogStub, `> ${path.join('~', 'test')} --foo ${path.join('~', 'boo')}`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS at the beginning of the path between doble quotes', async () => { @@ -120,7 +104,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(`"${path.join(untildify('~'), 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> "${path.join('~', 'test')}" "--foo" "--bar"`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS NOT at the beginning of the path', async () => { @@ -128,7 +112,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(path.join('net', untildify('~'), 'test'), ['--foo', '--bar'], options); sinon.assert.calledWithExactly(traceLogStub, `> ${path.join('net', '~', 'test')} --foo --bar`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS NOT at the beginning of the path but another arg contains other ref to home folder', async () => { @@ -143,7 +127,7 @@ suite('ProcessLogger suite', () => { traceLogStub, `> ${path.join('net', '~', 'test')} --foo ${path.join('~', 'boo')}`, ); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ in the command path where the home path IS NOT at the beginning of the path between doble quotes', async () => { @@ -151,7 +135,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(`"${path.join('net', untildify('~'), 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> "${path.join('net', '~', 'test')}" "--foo" "--bar"`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path/to/home with ~ if shell command is provided', async () => { @@ -159,7 +143,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(`"${path.join(untildify('~'), 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> "${path.join('~', 'test')}" "--foo" "--bar"`); - sinon.assert.calledWithExactly(traceLogStub, `${Logging.currentWorkingDirectory} ${options.cwd}`); + sinon.assert.calledWithExactly(traceLogStub, `cwd: ${options.cwd}`); }); test('Logger replaces the path to workspace with . if exactly one workspace folder is opened', async () => { @@ -167,10 +151,7 @@ suite('ProcessLogger suite', () => { logger.logProcess(`"${path.join('path', 'to', 'workspace', 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> ".${path.sep}test" "--foo" "--bar"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} .${path.sep + path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: .${path.sep + path.join('debug', 'path')}`); }); test('On Windows, logger replaces both backwards and forward slash version of path to workspace with . if exactly one workspace folder is opened', async function () { @@ -182,20 +163,14 @@ suite('ProcessLogger suite', () => { logger.logProcess(`"${path.join('path', 'to', 'workspace', 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> ".${path.sep}test" "--foo" "--bar"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} .${path.sep + path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: .${path.sep + path.join('debug', 'path')}`); traceLogStub.resetHistory(); options = { cwd: path.join('path\\to\\workspace', 'debug', 'path') }; logger.logProcess(`"${path.join('path', 'to', 'workspace', 'test')}" "--foo" "--bar"`, undefined, options); sinon.assert.calledWithExactly(traceLogStub, `> ".${path.sep}test" "--foo" "--bar"`); - sinon.assert.calledWithExactly( - traceLogStub, - `${Logging.currentWorkingDirectory} .${path.sep + path.join('debug', 'path')}`, - ); + sinon.assert.calledWithExactly(traceLogStub, `cwd: .${path.sep + path.join('debug', 'path')}`); }); test("Logger doesn't display the working directory line if there is no options parameter", async () => { diff --git a/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts b/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts index 1bb6f30705fd..5d764aac2536 100644 --- a/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts +++ b/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts @@ -32,7 +32,6 @@ import { EditorUtils } from '../../client/common/editor'; import { ProductInstaller } from '../../client/common/installer/productInstaller'; import { InterpreterPathService } from '../../client/common/interpreterPathService'; import { BrowserService } from '../../client/common/net/browser'; -import { HttpClient } from '../../client/common/net/httpClient'; import { PersistentStateFactory } from '../../client/common/persistentState'; import { PathUtils } from '../../client/common/platform/pathUtils'; import { CurrentProcess } from '../../client/common/process/currentProcess'; @@ -65,7 +64,6 @@ import { ICurrentProcess, IEditorUtils, IExtensions, - IHttpClient, IInstaller, IInterpreterPathService, IPathUtils, @@ -104,7 +102,6 @@ suite('Common - Service Registry', () => { [IApplicationEnvironment, ApplicationEnvironment], [ILanguageService, LanguageService], [IBrowserService, BrowserService], - [IHttpClient, HttpClient], [IEditorUtils, EditorUtils], [ITerminalActivator, TerminalActivator], [ITerminalActivationHandler, PowershellTerminalActivationFailedHandler], diff --git a/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts b/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts index e26f09fd516a..904752d698c9 100644 --- a/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts @@ -3,7 +3,6 @@ import { expect } from 'chai'; import * as path from 'path'; -import { parse } from 'semver'; import { anything, instance, mock, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; import { Disposable } from 'vscode'; @@ -145,37 +144,6 @@ suite('Terminal Environment Activation conda', () => { expect(activationCommands).to.deep.equal(expected, 'Incorrect Activation command'); }); - test('Conda activation on bash uses "source" before 4.4.0', async () => { - const envName = 'EnvA'; - const pythonPath = 'python3'; - const condaPath = path.join('a', 'b', 'c', 'conda'); - platformService.setup((p) => p.isWindows).returns(() => false); - condaService.reset(); - componentAdapter - .setup((c) => c.getCondaEnvironment(TypeMoq.It.isAny())) - .returns(() => - Promise.resolve({ - name: envName, - path: path.dirname(pythonPath), - }), - ); - condaService.setup((c) => c.getCondaFile()).returns(() => Promise.resolve(condaPath)); - condaService.setup((c) => c.getCondaVersion()).returns(() => Promise.resolve(parse('4.3.1', true)!)); - const expected = [ - `source ${path.join(path.dirname(condaPath), 'activate').fileToCommandArgumentForPythonExt()} EnvA`, - ]; - - const provider = new CondaActivationCommandProvider( - condaService.object, - platformService.object, - configService.object, - componentAdapter.object, - ); - const activationCommands = await provider.getActivationCommands(undefined, TerminalShellType.bash); - - expect(activationCommands).to.deep.equal(expected, 'Incorrect Activation command'); - }); - test('Conda activation on bash uses "conda" after 4.4.0', async () => { const envName = 'EnvA'; const pythonPath = 'python3'; @@ -191,7 +159,6 @@ suite('Terminal Environment Activation conda', () => { }), ); condaService.setup((c) => c.getCondaFile()).returns(() => Promise.resolve(condaPath)); - condaService.setup((c) => c.getCondaVersion()).returns(() => Promise.resolve(parse('4.4.0', true)!)); const expected = [ `source ${path.join(path.dirname(condaPath), 'activate').fileToCommandArgumentForPythonExt()} EnvA`, ]; @@ -308,7 +275,6 @@ suite('Terminal Environment Activation conda', () => { path: path.dirname(pythonPath), }), ); - condaService.setup((c) => c.getCondaVersion()).returns(() => Promise.resolve(parse('4.4.0', true)!)); condaService .setup((c) => c.getCondaFileFromInterpreter(TypeMoq.It.isAny(), TypeMoq.It.isAny())) .returns(() => Promise.resolve(interpreterPath)); diff --git a/extensions/positron-python/src/test/configuration/interpreterSelector/commands/installPythonViaTerminal.unit.test.ts b/extensions/positron-python/src/test/configuration/interpreterSelector/commands/installPythonViaTerminal.unit.test.ts index 91f73cb0a305..16014290c218 100644 --- a/extensions/positron-python/src/test/configuration/interpreterSelector/commands/installPythonViaTerminal.unit.test.ts +++ b/extensions/positron-python/src/test/configuration/interpreterSelector/commands/installPythonViaTerminal.unit.test.ts @@ -96,15 +96,22 @@ suite('Install Python via Terminal', () => { await installPythonCommand.activate(); await installCommandHandler!(); - expect(message).to.be.equal(Interpreters.installPythonTerminalMessage); + expect(message).to.be.equal(Interpreters.installPythonTerminalMessageLinux); }); - test('Sends expected commands on Mac when InstallPythonOnMac command is executed if no dnf is available', async () => { + test('Sends expected commands on Mac when InstallPythonOnMac command is executed if brew is available', async () => { let installCommandHandler: () => Promise; when(cmdManager.registerCommand(Commands.InstallPythonOnMac, anything())).thenCall((_, cb) => { installCommandHandler = cb; return TypeMoq.Mock.ofType().object; }); + rewiremock('which').with((cmd: string) => { + if (cmd === 'brew') { + return 'path/to/brew'; + } + throw new Error('Command not found'); + }); + await installPythonCommand.activate(); when(terminalService.sendText('brew install python3')).thenResolve(); @@ -113,4 +120,21 @@ suite('Install Python via Terminal', () => { verify(terminalService.sendText('brew install python3')).once(); expect(message).to.be.equal(undefined); }); + + test('Creates terminal with appropriate message when InstallPythonOnMac command is executed if brew is not available', async () => { + let installCommandHandler: () => Promise; + when(cmdManager.registerCommand(Commands.InstallPythonOnMac, anything())).thenCall((_, cb) => { + installCommandHandler = cb; + return TypeMoq.Mock.ofType().object; + }); + rewiremock('which').with((_cmd: string) => { + throw new Error('Command not found'); + }); + + await installPythonCommand.activate(); + + await installCommandHandler!(); + + expect(message).to.be.equal(Interpreters.installPythonTerminalMacMessage); + }); }); diff --git a/extensions/positron-python/src/test/debugger/envVars.test.ts b/extensions/positron-python/src/test/debugger/envVars.test.ts index 71c5b8e62650..6aa0dea4d8c6 100644 --- a/extensions/positron-python/src/test/debugger/envVars.test.ts +++ b/extensions/positron-python/src/test/debugger/envVars.test.ts @@ -76,6 +76,27 @@ suite('Resolving Environment Variables when Debugging', () => { test('Confirm basic environment variables exist when launched in intergrated terminal', () => testBasicProperties('integratedTerminal', 2)); + test('Confirm base environment variables are merged without overwriting when provided', async () => { + const env: Record = { DO_NOT_OVERWRITE: '1' }; + const args = ({ + program: '', + pythonPath: '', + args: [], + envFile: '', + console, + env, + } as any) as LaunchRequestArguments; + + const baseEnvVars = { CONDA_PREFIX: 'path/to/conda/env', DO_NOT_OVERWRITE: '0' }; + const envVars = await debugEnvParser.getEnvironmentVariables(args, baseEnvVars); + expect(envVars).not.be.undefined; + expect(Object.keys(envVars)).lengthOf(4, 'Incorrect number of variables'); + expect(envVars).to.have.property('PYTHONUNBUFFERED', '1', 'Property not found'); + expect(envVars).to.have.property('PYTHONIOENCODING', 'UTF-8', 'Property not found'); + expect(envVars).to.have.property('CONDA_PREFIX', 'path/to/conda/env', 'Property not found'); + expect(envVars).to.have.property('DO_NOT_OVERWRITE', '1', 'Property not found'); + }); + test('Confirm basic environment variables exist when launched in debug console', async () => { let expectedNumberOfVariables = Object.keys(mockProcess.env).length; if (mockProcess.env['PYTHONUNBUFFERED'] === undefined) { diff --git a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/attach.unit.test.ts b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/attach.unit.test.ts index 9d271b7acd90..b245a0b4622f 100644 --- a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/attach.unit.test.ts +++ b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/attach.unit.test.ts @@ -26,11 +26,9 @@ getInfoPerOS().forEach(([osName, osType, path]) => { const options = [DebugOptions.RedirectOutput]; if (osType === platform.OSType.Windows) { options.push(DebugOptions.FixFilePathCase); - options.push(DebugOptions.WindowsClient); - } else { - options.push(DebugOptions.UnixClient); } options.push(DebugOptions.ShowReturnValue); + return options; } @@ -76,6 +74,10 @@ getInfoPerOS().forEach(([osName, osType, path]) => { } } + function getClientOS() { + return osType === platform.OSType.Windows ? 'windows' : 'unix'; + } + function setupWorkspaces(folders: string[]) { const workspaceFolders = folders.map(createMoqWorkspaceFolder); getWorkspaceFoldersStub.returns(workspaceFolders); @@ -119,6 +121,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('request', 'attach'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').deep.equal(debugOptionsAvailable); }); @@ -134,6 +137,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.least(3); expect(debugConfig).to.have.property('request', 'attach'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').deep.equal(debugOptionsAvailable); expect(debugConfig).to.have.property('host', 'localhost'); }); @@ -148,6 +152,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.least(3); expect(debugConfig).to.have.property('request', 'attach'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').deep.equal(debugOptionsAvailable); expect(debugConfig).to.have.property('host', 'localhost'); }); @@ -164,6 +169,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.least(3); expect(debugConfig).to.have.property('request', 'attach'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').deep.equal(debugOptionsAvailable); expect(debugConfig).to.not.have.property('localRoot'); expect(debugConfig).to.have.property('host', 'localhost'); @@ -181,6 +187,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.least(3); expect(debugConfig).to.have.property('request', 'attach'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').deep.equal(debugOptionsAvailable); expect(debugConfig).to.have.property('host', 'localhost'); }); @@ -486,6 +493,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { debugOptions, }); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('debugOptions').to.be.deep.equal(expectedDebugOptions); }); diff --git a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/base.unit.test.ts b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/base.unit.test.ts index b4478f7c3f0a..4da645bc34ac 100644 --- a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/base.unit.test.ts +++ b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/base.unit.test.ts @@ -158,7 +158,7 @@ suite('Debugging - Config Resolver', () => { test('Do nothing if debug configuration is undefined', async () => { await resolver.resolveAndUpdatePythonPath(undefined, (undefined as unknown) as LaunchRequestArguments); }); - test('pythonPath in debug config must point to pythonPath in settings if pythonPath in config is not set', async () => { + test('python in debug config must point to pythonPath in settings if pythonPath in config is not set', async () => { const config = {}; const pythonPath = path.join('1', '2', '3'); @@ -168,11 +168,11 @@ suite('Debugging - Config Resolver', () => { await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); - expect(config).to.have.property('pythonPath', pythonPath); + expect(config).to.have.property('python', pythonPath); }); - test('pythonPath in debug config must point to pythonPath in settings if pythonPath in config is ${command:python.interpreterPath}', async () => { + test('python in debug config must point to pythonPath in settings if pythonPath in config is ${command:python.interpreterPath}', async () => { const config = { - pythonPath: '${command:python.interpreterPath}', + python: '${command:python.interpreterPath}', }; const pythonPath = path.join('1', '2', '3'); @@ -182,8 +182,113 @@ suite('Debugging - Config Resolver', () => { await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); - expect(config.pythonPath).to.equal(pythonPath); + expect(config.python).to.equal(pythonPath); }); + + test('config should only contain python and not pythonPath after resolving', async () => { + const config = { pythonPath: '${command:python.interpreterPath}', python: '${command:python.interpreterPath}' }; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + }); + + test('config should convert pythonPath to python, only if python is not set', async () => { + const config = { pythonPath: '${command:python.interpreterPath}', python: undefined }; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + }); + + test('config should not change python if python is different than pythonPath', async () => { + const expected = path.join('1', '2', '4'); + const config = { pythonPath: '${command:python.interpreterPath}', python: expected }; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', expected); + }); + + test('config should get python from interpreter service is nothing is set', async () => { + const config = {}; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + }); + + test('config should contain debugAdapterPython and debugLauncherPython', async () => { + const config = {}; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + expect(config).to.have.property('debugAdapterPython', pythonPath); + expect(config).to.have.property('debugLauncherPython', pythonPath); + }); + + test('config should not change debugAdapterPython and debugLauncherPython if already set', async () => { + const debugAdapterPythonPath = path.join('1', '2', '4'); + const debugLauncherPythonPath = path.join('1', '2', '5'); + + const config = { debugAdapterPython: debugAdapterPythonPath, debugLauncherPython: debugLauncherPythonPath }; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + expect(config).to.have.property('debugAdapterPython', debugAdapterPythonPath); + expect(config).to.have.property('debugLauncherPython', debugLauncherPythonPath); + }); + + test('config should not resolve debugAdapterPython and debugLauncherPython', async () => { + const config = { + debugAdapterPython: '${command:python.interpreterPath}', + debugLauncherPython: '${command:python.interpreterPath}', + }; + const pythonPath = path.join('1', '2', '3'); + + when(interpreterService.getActiveInterpreter(anything())).thenResolve({ + path: pythonPath, + } as PythonEnvironment); + + await resolver.resolveAndUpdatePythonPath(undefined, config as LaunchRequestArguments); + expect(config).to.not.have.property('pythonPath'); + expect(config).to.have.property('python', pythonPath); + expect(config).to.have.property('debugAdapterPython', pythonPath); + expect(config).to.have.property('debugLauncherPython', pythonPath); + }); + const localHostTestMatrix: Record = { localhost: true, '127.0.0.1': true, diff --git a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/launch.unit.test.ts b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/launch.unit.test.ts index e7e256468f84..2aec3dcfd041 100644 --- a/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/launch.unit.test.ts +++ b/extensions/positron-python/src/test/debugger/extension/configuration/resolvers/launch.unit.test.ts @@ -21,6 +21,7 @@ import { getInfoPerOS } from './common'; import * as platform from '../../../../../client/common/utils/platform'; import * as windowApis from '../../../../../client/common/vscodeApis/windowApis'; import * as workspaceApis from '../../../../../client/common/vscodeApis/workspaceApis'; +import { IEnvironmentActivationService } from '../../../../../client/interpreter/activation/types'; getInfoPerOS().forEach(([osName, osType, path]) => { if (osType === platform.OSType.Unknown) { @@ -31,12 +32,13 @@ getInfoPerOS().forEach(([osName, osType, path]) => { let debugProvider: DebugConfigurationProvider; let pythonExecutionService: TypeMoq.IMock; let helper: TypeMoq.IMock; + const envVars = { FOO: 'BAR' }; let diagnosticsService: TypeMoq.IMock; let configService: TypeMoq.IMock; let debugEnvHelper: TypeMoq.IMock; let interpreterService: TypeMoq.IMock; - + let environmentActivationService: TypeMoq.IMock; let getActiveTextEditorStub: sinon.SinonStub; let getOSTypeStub: sinon.SinonStub; let getWorkspaceFolderStub: sinon.SinonStub; @@ -58,7 +60,15 @@ getInfoPerOS().forEach(([osName, osType, path]) => { return folder.object; } + function getClientOS() { + return osType === platform.OSType.Windows ? 'windows' : 'unix'; + } + function setupIoc(pythonPath: string, workspaceFolder?: WorkspaceFolder) { + environmentActivationService = TypeMoq.Mock.ofType(); + environmentActivationService + .setup((e) => e.getActivatedEnvironmentVariables(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(envVars)); configService = TypeMoq.Mock.ofType(); diagnosticsService = TypeMoq.Mock.ofType(); debugEnvHelper = TypeMoq.Mock.ofType(); @@ -84,7 +94,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { } configService.setup((c) => c.getSettings(TypeMoq.It.isAny())).returns(() => settings.object); debugEnvHelper - .setup((x) => x.getEnvironmentVariables(TypeMoq.It.isAny())) + .setup((x) => x.getEnvironmentVariables(TypeMoq.It.isAny(), TypeMoq.It.isAny())) .returns(() => Promise.resolve({})); debugProvider = new LaunchConfigurationResolver( @@ -92,6 +102,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { configService.object, debugEnvHelper.object, interpreterService.object, + environmentActivationService.object, ); } @@ -160,6 +171,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); expect(debugConfig).to.have.property('request', 'launch'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -188,6 +200,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); expect(debugConfig).to.have.property('request', 'launch'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -215,6 +228,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); expect(debugConfig).to.have.property('request', 'launch'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -239,6 +253,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -264,6 +279,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); expect(debugConfig).to.have.property('request', 'launch'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -290,6 +306,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { expect(Object.keys(debugConfig!)).to.have.lengthOf.above(3); expect(debugConfig).to.have.property('type', 'python'); expect(debugConfig).to.have.property('request', 'launch'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.not.have.property('pythonPath'); expect(debugConfig).to.have.property('python', pythonPath); expect(debugConfig).to.have.property('debugAdapterPython', pythonPath); @@ -692,6 +709,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { }); expect(debugConfig).to.have.property('console', 'integratedTerminal'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('stopOnEntry', false); expect(debugConfig).to.have.property('showReturnValue', true); expect(debugConfig).to.have.property('debugOptions'); @@ -717,6 +735,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { }); expect(debugConfig).to.have.property('stopOnEntry', false); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('showReturnValue', true); expect(debugConfig).to.have.property('debugOptions'); expect((debugConfig as DebugConfiguration).debugOptions).to.be.deep.equal([]); @@ -736,6 +755,7 @@ getInfoPerOS().forEach(([osName, osType, path]) => { }); expect(debugConfig).to.have.property('console', 'integratedTerminal'); + expect(debugConfig).to.have.property('clientOS', getClientOS()); expect(debugConfig).to.have.property('stopOnEntry', false); expect(debugConfig).to.have.property('showReturnValue', true); expect(debugConfig).to.have.property('redirectOutput', true); diff --git a/extensions/positron-python/src/test/proposedApi.unit.test.ts b/extensions/positron-python/src/test/environmentApi.unit.test.ts similarity index 91% rename from extensions/positron-python/src/test/proposedApi.unit.test.ts rename to extensions/positron-python/src/test/environmentApi.unit.test.ts index 5dfa54492c6b..a4ea73fb6c92 100644 --- a/extensions/positron-python/src/test/proposedApi.unit.test.ts +++ b/extensions/positron-python/src/test/environmentApi.unit.test.ts @@ -16,12 +16,12 @@ import { } from '../client/common/types'; import { IServiceContainer } from '../client/ioc/types'; import { - buildProposedApi, + buildEnvironmentApi, convertCompleteEnvInfo, convertEnvInfo, EnvironmentReference, reportActiveInterpreterChanged, -} from '../client/proposedApi'; +} from '../client/environmentApi'; import { IDiscoveryAPI, ProgressNotificationEvent } from '../client/pythonEnvironments/base/locator'; import { buildEnvInfo } from '../client/pythonEnvironments/base/info/env'; import { sleep } from './core'; @@ -29,17 +29,17 @@ import { PythonEnvKind, PythonEnvSource } from '../client/pythonEnvironments/bas import { Architecture } from '../client/common/utils/platform'; import { PythonEnvCollectionChangedEvent } from '../client/pythonEnvironments/base/watcher'; import { normCasePath } from '../client/common/platform/fs-paths'; -import { - ActiveEnvironmentPathChangeEvent, - EnvironmentsChangeEvent, - EnvironmentVariablesChangeEvent, - ProposedExtensionAPI, -} from '../client/proposedApiTypes'; import { IWorkspaceService } from '../client/common/application/types'; import { IEnvironmentVariablesProvider } from '../client/common/variables/types'; import * as workspaceApis from '../client/common/vscodeApis/workspaceApis'; +import { + ActiveEnvironmentPathChangeEvent, + EnvironmentVariablesChangeEvent, + EnvironmentsChangeEvent, + IExtensionApi, +} from '../client/apiTypes'; -suite('Proposed Extension API', () => { +suite('Python Environment API', () => { const workspacePath = 'path/to/workspace'; const workspaceFolder = { name: 'workspace', @@ -57,7 +57,7 @@ suite('Proposed Extension API', () => { let onDidChangeEnvironments: EventEmitter; let onDidChangeEnvironmentVariables: EventEmitter; - let proposed: ProposedExtensionAPI; + let environmentApi: IExtensionApi['environments']; setup(() => { serviceContainer = typemoq.Mock.ofType(); @@ -95,10 +95,12 @@ suite('Proposed Extension API', () => { discoverAPI.setup((d) => d.onProgress).returns(() => onDidChangeRefreshState.event); discoverAPI.setup((d) => d.onChanged).returns(() => onDidChangeEnvironments.event); - proposed = buildProposedApi(discoverAPI.object, serviceContainer.object); + environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object); }); teardown(() => { + // Verify each API method sends telemetry regarding who called the API. + extensions.verifyAll(); sinon.restore(); }); @@ -107,7 +109,7 @@ suite('Proposed Extension API', () => { const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); const events: EnvironmentVariablesChangeEvent[] = []; - proposed.environments.onDidEnvironmentVariablesChange((e) => { + environmentApi.onDidEnvironmentVariablesChange((e) => { events.push(e); }); onDidChangeEnvironmentVariables.fire(resource); @@ -119,7 +121,7 @@ suite('Proposed Extension API', () => { const resource = undefined; const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(resource); + const vars = environmentApi.getEnvironmentVariables(resource); assert.deepEqual(vars, envVars); }); @@ -127,7 +129,7 @@ suite('Proposed Extension API', () => { const resource = Uri.file('x'); const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(resource); + const vars = environmentApi.getEnvironmentVariables(resource); assert.deepEqual(vars, envVars); }); @@ -136,13 +138,13 @@ suite('Proposed Extension API', () => { const folder = ({ uri: resource } as unknown) as WorkspaceFolder; const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - const vars = proposed.environments.getEnvironmentVariables(folder); + const vars = environmentApi.getEnvironmentVariables(folder); assert.deepEqual(vars, envVars); }); test('Provide an event to track when active environment details change', async () => { const events: ActiveEnvironmentPathChangeEvent[] = []; - proposed.environments.onDidChangeActiveEnvironmentPath((e) => { + environmentApi.onDidChangeActiveEnvironmentPath((e) => { events.push(e); }); reportActiveInterpreterChanged({ path: 'path/to/environment', resource: undefined }); @@ -157,7 +159,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(undefined)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(); + const actual = environmentApi.getActiveEnvironmentPath(); assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath, @@ -169,7 +171,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(undefined)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(); + const actual = environmentApi.getActiveEnvironmentPath(); assert.deepEqual(actual, { id: 'DEFAULT_PYTHON', path: pythonPath, @@ -182,7 +184,7 @@ suite('Proposed Extension API', () => { configService .setup((c) => c.getSettings(resource)) .returns(() => (({ pythonPath } as unknown) as IPythonSettings)); - const actual = proposed.environments.getActiveEnvironmentPath(resource); + const actual = environmentApi.getActiveEnvironmentPath(resource); assert.deepEqual(actual, { id: normCasePath(pythonPath), path: pythonPath, @@ -193,7 +195,7 @@ suite('Proposed Extension API', () => { const pythonPath = 'this/is/a/test/path'; discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(undefined)); - const actual = await proposed.environments.resolveEnvironment(pythonPath); + const actual = await environmentApi.resolveEnvironment(pythonPath); expect(actual).to.be.equal(undefined); }); @@ -213,7 +215,7 @@ suite('Proposed Extension API', () => { }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); - const actual = await proposed.environments.resolveEnvironment(pythonPath); + const actual = await environmentApi.resolveEnvironment(pythonPath); assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); }); @@ -239,13 +241,13 @@ suite('Proposed Extension API', () => { }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); - const actual = await proposed.environments.resolveEnvironment(convertCompleteEnvInfo(partialEnv)); + const actual = await environmentApi.resolveEnvironment(convertCompleteEnvInfo(partialEnv)); assert.deepEqual((actual as EnvironmentReference).internal, convertCompleteEnvInfo(env)); }); test('environments: no pythons found', () => { discoverAPI.setup((d) => d.getEnvs()).returns(() => []); - const actual = proposed.environments.known; + const actual = environmentApi.known; expect(actual).to.be.deep.equal([]); }); @@ -323,7 +325,7 @@ suite('Proposed Extension API', () => { }, ]; discoverAPI.setup((d) => d.getEnvs()).returns(() => envs); - const actual = proposed.environments.known; + const actual = environmentApi.known; const actualEnvs = actual?.map((a) => (a as EnvironmentReference).internal); assert.deepEqual( actualEnvs?.sort((a, b) => a.id.localeCompare(b.id)), @@ -335,7 +337,7 @@ suite('Proposed Extension API', () => { let events: EnvironmentsChangeEvent[] = []; let eventValues: EnvironmentsChangeEvent[] = []; let expectedEvents: EnvironmentsChangeEvent[] = []; - proposed.environments.onDidChangeEnvironments((e) => { + environmentApi.onDidChangeEnvironments((e) => { events.push(e); }); const envs = [ @@ -429,7 +431,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path'); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path'); interpreterPathService.verifyAll(); }); @@ -440,7 +442,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath({ + await environmentApi.updateActiveEnvironmentPath({ id: normCasePath('this/is/a/test/python/path'), path: 'this/is/a/test/python/path', }); @@ -455,7 +457,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path', uri); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path', uri); interpreterPathService.verifyAll(); }); @@ -472,7 +474,7 @@ suite('Proposed Extension API', () => { index: 0, }; - await proposed.environments.updateActiveEnvironmentPath('this/is/a/test/python/path', workspace); + await environmentApi.updateActiveEnvironmentPath('this/is/a/test/python/path', workspace); interpreterPathService.verifyAll(); }); @@ -483,7 +485,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.refreshEnvironments(); + await environmentApi.refreshEnvironments(); discoverAPI.verifyAll(); }); @@ -494,7 +496,7 @@ suite('Proposed Extension API', () => { .returns(() => Promise.resolve()) .verifiable(typemoq.Times.once()); - await proposed.environments.refreshEnvironments({ forceRefresh: true }); + await environmentApi.refreshEnvironments({ forceRefresh: true }); discoverAPI.verifyAll(); }); diff --git a/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts b/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts index d50b2b5d5995..002189d412db 100644 --- a/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts +++ b/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts @@ -18,7 +18,7 @@ import { ProcessServiceFactory } from '../../../client/common/process/processFac import { IProcessService, IProcessServiceFactory } from '../../../client/common/process/types'; import { TerminalHelper } from '../../../client/common/terminal/helper'; import { ITerminalHelper } from '../../../client/common/terminal/types'; -import { ICurrentProcess } from '../../../client/common/types'; +import { ICurrentProcess, Resource } from '../../../client/common/types'; import { getNamesAndValues } from '../../../client/common/utils/enum'; import { Architecture, OSType } from '../../../client/common/utils/platform'; import { EnvironmentVariablesProvider } from '../../../client/common/variables/environmentVariablesProvider'; @@ -48,7 +48,7 @@ suite('Interpreters Activation - Python Environment Variables', () => { let workspace: IWorkspaceService; let interpreterService: IInterpreterService; let onDidChangeEnvVariables: EventEmitter; - let onDidChangeInterpreter: EventEmitter; + let onDidChangeInterpreter: EventEmitter; const pythonInterpreter: PythonEnvironment = { path: '/foo/bar/python.exe', version: new SemVer('3.6.6-final'), @@ -68,7 +68,7 @@ suite('Interpreters Activation - Python Environment Variables', () => { interpreterService = mock(InterpreterService); workspace = mock(WorkspaceService); onDidChangeEnvVariables = new EventEmitter(); - onDidChangeInterpreter = new EventEmitter(); + onDidChangeInterpreter = new EventEmitter(); when(envVarsService.onDidEnvironmentVariablesChange).thenReturn(onDidChangeEnvVariables.event); when(interpreterService.onDidChangeInterpreter).thenReturn(onDidChangeInterpreter.event); when(interpreterService.getActiveInterpreter(anything())).thenResolve(interpreter); @@ -322,9 +322,6 @@ suite('Interpreters Activation - Python Environment Variables', () => { verify(envVarsService.getEnvironmentVariables(resource)).twice(); verify(processService.shellExec(anything(), anything())).twice(); } - test('Cache Variables get cleared when changing interpreter', async () => { - await testClearingCache(onDidChangeInterpreter.fire.bind(onDidChangeInterpreter)); - }); test('Cache Variables get cleared when changing env variables file', async () => { await testClearingCache(onDidChangeEnvVariables.fire.bind(onDidChangeEnvVariables)); }); diff --git a/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts new file mode 100644 index 000000000000..4ac04cf1ee22 --- /dev/null +++ b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as sinon from 'sinon'; +import { assert, expect } from 'chai'; +import { cloneDeep } from 'lodash'; +import { mock, instance, when, anything, verify, reset } from 'ts-mockito'; +import { EnvironmentVariableCollection, ProgressLocation, Uri } from 'vscode'; +import { IApplicationShell, IApplicationEnvironment } from '../../../client/common/application/types'; +import { TerminalEnvVarActivation } from '../../../client/common/experiments/groups'; +import { IPlatformService } from '../../../client/common/platform/types'; +import { IExtensionContext, IExperimentService, Resource } from '../../../client/common/types'; +import { Interpreters } from '../../../client/common/utils/localize'; +import { getOSType } from '../../../client/common/utils/platform'; +import { defaultShells } from '../../../client/interpreter/activation/service'; +import { + TerminalEnvVarCollectionService, + _normCaseKeys, +} from '../../../client/interpreter/activation/terminalEnvVarCollectionService'; +import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; + +suite('Terminal Environment Variable Collection Service', () => { + let platform: IPlatformService; + let interpreterService: IInterpreterService; + let context: IExtensionContext; + let shell: IApplicationShell; + let experimentService: IExperimentService; + let collection: EnvironmentVariableCollection; + let applicationEnvironment: IApplicationEnvironment; + let environmentActivationService: IEnvironmentActivationService; + let terminalEnvVarCollectionService: TerminalEnvVarCollectionService; + const progressOptions = { + location: ProgressLocation.Window, + title: Interpreters.activatingTerminals, + }; + const customShell = 'powershell'; + const defaultShell = defaultShells[getOSType()]; + + setup(() => { + platform = mock(); + when(platform.osType).thenReturn(getOSType()); + interpreterService = mock(); + context = mock(); + shell = mock(); + collection = mock(); + when(context.environmentVariableCollection).thenReturn(instance(collection)); + experimentService = mock(); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(true); + applicationEnvironment = mock(); + when(applicationEnvironment.shell).thenReturn(customShell); + when(shell.withProgress(anything(), anything())) + .thenCall((options, _) => { + expect(options).to.deep.equal(progressOptions); + }) + .thenResolve(); + environmentActivationService = mock(); + terminalEnvVarCollectionService = new TerminalEnvVarCollectionService( + instance(platform), + instance(interpreterService), + instance(context), + instance(shell), + instance(experimentService), + instance(applicationEnvironment), + [], + instance(environmentActivationService), + ); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Apply activated variables to the collection on activation', async () => { + const applyCollectionStub = sinon.stub(terminalEnvVarCollectionService, '_applyCollection'); + applyCollectionStub.resolves(); + when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); + when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); + await terminalEnvVarCollectionService.activate(); + assert(applyCollectionStub.calledOnce, 'Collection not applied on activation'); + }); + + test('When not in experiment, do not apply activated variables to the collection and clear it instead', async () => { + reset(experimentService); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(false); + const applyCollectionStub = sinon.stub(terminalEnvVarCollectionService, '_applyCollection'); + applyCollectionStub.resolves(); + when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); + when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService.activate(); + + verify(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).never(); + verify(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).never(); + assert(applyCollectionStub.notCalled, 'Collection should not be applied on activation'); + + verify(collection.clear()).once(); + }); + + test('When interpreter changes, apply new activated variables to the collection', async () => { + const applyCollectionStub = sinon.stub(terminalEnvVarCollectionService, '_applyCollection'); + applyCollectionStub.resolves(); + const resource = Uri.file('x'); + let callback: (resource: Resource) => Promise; + when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenCall((cb) => { + callback = cb; + }); + when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); + await terminalEnvVarCollectionService.activate(); + + await callback!(resource); + assert(applyCollectionStub.calledWithExactly(resource)); + }); + + test('When selected shell changes, apply new activated variables to the collection', async () => { + const applyCollectionStub = sinon.stub(terminalEnvVarCollectionService, '_applyCollection'); + applyCollectionStub.resolves(); + let callback: (shell: string) => Promise; + when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenCall((cb) => { + callback = cb; + }); + when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); + await terminalEnvVarCollectionService.activate(); + + await callback!(customShell); + assert(applyCollectionStub.calledWithExactly(undefined, customShell)); + }); + + test('If activated variables are returned for custom shell, apply it correctly to the collection', async () => { + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; + delete envVars.PATH; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda')).once(); + verify(collection.delete('PATH')).once(); + }); + + test('Only relative changes to previously applied variables are applied to the collection', async () => { + const envVars: NodeJS.ProcessEnv = { + RANDOM_VAR: 'random', + CONDA_PREFIX: 'prefix/to/conda', + ..._normCaseKeys(process.env), + }; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + const newEnvVars = cloneDeep(envVars); + delete newEnvVars.CONDA_PREFIX; + newEnvVars.RANDOM_VAR = undefined; // Deleting the variable from the collection is the same as setting it to undefined. + reset(environmentActivationService); + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(newEnvVars); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.delete('CONDA_PREFIX')).once(); + verify(collection.delete('RANDOM_VAR')).once(); + }); + + test('If no activated variables are returned for custom shell, fallback to using default shell', async () => { + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(undefined); + const envVars = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + defaultShell?.shell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda')).once(); + verify(collection.delete(anything())).never(); + }); + + test('If no activated variables are returned for default shell, clear collection', async () => { + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + defaultShell?.shell, + ), + ).thenResolve(undefined); + + when(collection.replace(anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + + await terminalEnvVarCollectionService._applyCollection(undefined, defaultShell?.shell); + + verify(collection.clear()).once(); + }); +}); diff --git a/extensions/positron-python/src/test/interpreters/interpreterService.unit.test.ts b/extensions/positron-python/src/test/interpreters/interpreterService.unit.test.ts index 1bbf729e53b9..d8a0ada23a6f 100644 --- a/extensions/positron-python/src/test/interpreters/interpreterService.unit.test.ts +++ b/extensions/positron-python/src/test/interpreters/interpreterService.unit.test.ts @@ -35,7 +35,7 @@ import { ServiceContainer } from '../../client/ioc/container'; import { ServiceManager } from '../../client/ioc/serviceManager'; import { PYTHON_PATH } from '../common'; import { MockAutoSelectionService } from '../mocks/autoSelector'; -import * as proposedApi from '../../client/proposedApi'; +import * as proposedApi from '../../client/environmentApi'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/extensions/positron-python/src/test/languageServer/pylanceLSExtensionManager.unit.test.ts b/extensions/positron-python/src/test/languageServer/pylanceLSExtensionManager.unit.test.ts index 0118cca0764f..751b26d37d3c 100644 --- a/extensions/positron-python/src/test/languageServer/pylanceLSExtensionManager.unit.test.ts +++ b/extensions/positron-python/src/test/languageServer/pylanceLSExtensionManager.unit.test.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import * as assert from 'assert'; -import { LspNotebooksExperiment } from '../../client/activation/node/lspNotebooksExperiment'; import { ILanguageServerOutputChannel } from '../../client/activation/types'; import { IWorkspaceService, ICommandManager, IApplicationShell } from '../../client/common/application/types'; import { IFileSystem } from '../../client/common/platform/types'; @@ -38,7 +37,6 @@ suite('Language Server - Pylance LS extension manager', () => { {} as IFileSystem, {} as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, ); }); @@ -67,7 +65,6 @@ suite('Language Server - Pylance LS extension manager', () => { getExtension: () => ({}), } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, ); const result = manager.canStartLanguageServer(); @@ -95,7 +92,6 @@ suite('Language Server - Pylance LS extension manager', () => { getExtension: () => undefined, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, ); const result = manager.canStartLanguageServer(); diff --git a/extensions/positron-python/src/test/languageServer/watcher.unit.test.ts b/extensions/positron-python/src/test/languageServer/watcher.unit.test.ts index 61628257fc90..e86e19cf2055 100644 --- a/extensions/positron-python/src/test/languageServer/watcher.unit.test.ts +++ b/extensions/positron-python/src/test/languageServer/watcher.unit.test.ts @@ -5,7 +5,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { ConfigurationChangeEvent, Uri, WorkspaceFolder, WorkspaceFoldersChangeEvent } from 'vscode'; import { JediLanguageServerManager } from '../../client/activation/jedi/manager'; -import { LspNotebooksExperiment } from '../../client/activation/node/lspNotebooksExperiment'; import { NodeLanguageServerManager } from '../../client/activation/node/manager'; import { ILanguageServerOutputChannel, LanguageServerType } from '../../client/activation/types'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../client/common/application/types'; @@ -80,7 +79,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); @@ -130,7 +128,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -176,7 +173,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -253,7 +249,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -330,7 +325,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -410,7 +404,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -480,7 +473,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -544,7 +536,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -608,7 +599,6 @@ suite('Language server watcher', () => { ({ showWarningMessage: () => Promise.resolve(undefined), } as unknown) as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -667,7 +657,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -757,7 +746,6 @@ suite('Language server watcher', () => { ({ showWarningMessage: () => Promise.resolve(undefined), } as unknown) as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -837,7 +825,6 @@ suite('Language server watcher', () => { ({ showWarningMessage: () => Promise.resolve(undefined), } as unknown) as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -926,7 +913,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -1007,7 +993,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -1091,7 +1076,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); @@ -1175,7 +1159,6 @@ suite('Language server watcher', () => { }, } as unknown) as IExtensions, {} as IApplicationShell, - {} as LspNotebooksExperiment, disposables, ); watcher.register(); diff --git a/extensions/positron-python/src/test/linters/lint.provider.test.ts b/extensions/positron-python/src/test/linters/lint.provider.test.ts index 680dfecc0277..760c2282ba05 100644 --- a/extensions/positron-python/src/test/linters/lint.provider.test.ts +++ b/extensions/positron-python/src/test/linters/lint.provider.test.ts @@ -24,6 +24,7 @@ import { IPersistentStateFactory, IPythonSettings, Product, + Resource, WORKSPACE_MEMENTO, } from '../../client/common/types'; import { createDeferred } from '../../client/common/utils/async'; @@ -171,12 +172,12 @@ suite('Linting - Provider', () => { }); test('Lint on change interpreters', async () => { - const e = new vscode.EventEmitter(); + const e = new vscode.EventEmitter(); interpreterService.setup((x) => x.onDidChangeInterpreter).returns(() => e.event); const linterProvider = new LinterProvider(serviceContainer); await linterProvider.activate(); - e.fire(); + e.fire(undefined); engine.verify((x) => x.lintOpenPythonFiles(), TypeMoq.Times.once()); }); diff --git a/extensions/positron-python/src/test/performanceTest.ts b/extensions/positron-python/src/test/performanceTest.ts index b4e66397e849..d4ac6bf262d0 100644 --- a/extensions/positron-python/src/test/performanceTest.ts +++ b/extensions/positron-python/src/test/performanceTest.ts @@ -19,7 +19,7 @@ import { spawn } from 'child_process'; import * as download from 'download'; import * as fs from 'fs-extra'; import * as path from 'path'; -import * as request from 'request'; +import * as bent from 'bent'; import { LanguageServerType } from '../client/activation/types'; import { EXTENSION_ROOT_DIR, PVSC_EXTENSION_ID } from '../client/common/constants'; import { unzip } from './common'; @@ -123,17 +123,9 @@ class TestRunner { private async getReleaseVersion(): Promise { const url = `https://marketplace.visualstudio.com/items?itemName=${PVSC_EXTENSION_ID}`; - const content = await new Promise((resolve, reject) => { - request(url, (error, response, body) => { - if (error) { - return reject(error); - } - if (response.statusCode === 200) { - return resolve(body); - } - reject(`Status code of ${response.statusCode} received.`); - }); - }); + const request = bent('string', 'GET', 200); + + const content: string = await request(url); const re = NamedRegexp('"version"S?:S?"(:\\d{4}\\.\\d{1,2}\\.\\d{1,2})"', 'g'); const matches = re.exec(content); return matches.groups().version; diff --git a/extensions/positron-python/src/test/providers/prompt/installFormatterPrompt.unit.test.ts b/extensions/positron-python/src/test/providers/prompt/installFormatterPrompt.unit.test.ts new file mode 100644 index 000000000000..fbd3a72d8cef --- /dev/null +++ b/extensions/positron-python/src/test/providers/prompt/installFormatterPrompt.unit.test.ts @@ -0,0 +1,335 @@ +// 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 { WorkspaceConfiguration } from 'vscode'; +import { IPersistentState } from '../../../client/common/types'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import * as extensionsApi from '../../../client/common/vscodeApis/extensionsApi'; +import { IServiceContainer } from '../../../client/ioc/types'; +import { InstallFormatterPrompt } from '../../../client/providers/prompts/installFormatterPrompt'; +import * as promptUtils from '../../../client/providers/prompts/promptUtils'; +import { AUTOPEP8_EXTENSION, BLACK_EXTENSION, IInstallFormatterPrompt } from '../../../client/providers/prompts/types'; +import { Common, ToolsExtensions } from '../../../client/common/utils/localize'; + +suite('Formatter Extension prompt tests', () => { + let inFormatterExtensionExperimentStub: sinon.SinonStub; + let doNotShowPromptStateStub: sinon.SinonStub; + let prompt: IInstallFormatterPrompt; + let serviceContainer: TypeMoq.IMock; + let persistState: TypeMoq.IMock>; + let getConfigurationStub: sinon.SinonStub; + let isExtensionEnabledStub: sinon.SinonStub; + let pythonConfig: TypeMoq.IMock; + let editorConfig: TypeMoq.IMock; + let showInformationMessageStub: sinon.SinonStub; + let installFormatterExtensionStub: sinon.SinonStub; + let updateDefaultFormatterStub: sinon.SinonStub; + + setup(() => { + inFormatterExtensionExperimentStub = sinon.stub(promptUtils, 'inFormatterExtensionExperiment'); + inFormatterExtensionExperimentStub.returns(true); + + doNotShowPromptStateStub = sinon.stub(promptUtils, 'doNotShowPromptState'); + persistState = TypeMoq.Mock.ofType>(); + doNotShowPromptStateStub.returns(persistState.object); + + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + pythonConfig = TypeMoq.Mock.ofType(); + editorConfig = TypeMoq.Mock.ofType(); + getConfigurationStub.callsFake((section: string) => { + if (section === 'python') { + return pythonConfig.object; + } + return editorConfig.object; + }); + isExtensionEnabledStub = sinon.stub(extensionsApi, 'isExtensionEnabled'); + showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage'); + installFormatterExtensionStub = sinon.stub(promptUtils, 'installFormatterExtension'); + updateDefaultFormatterStub = sinon.stub(promptUtils, 'updateDefaultFormatter'); + + serviceContainer = TypeMoq.Mock.ofType(); + + prompt = new InstallFormatterPrompt(serviceContainer.object); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Not in experiment', async () => { + inFormatterExtensionExperimentStub.returns(false); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(doNotShowPromptStateStub.notCalled); + }); + + test('Do not show was set', async () => { + persistState.setup((p) => p.value).returns(() => true); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(getConfigurationStub.notCalled); + }); + + test('Formatting provider is set to none', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'none'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(isExtensionEnabledStub.notCalled); + }); + + test('Formatting provider is set to yapf', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'yapf'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(isExtensionEnabledStub.notCalled); + }); + + test('Formatting provider is set to autopep8, and autopep8 extension is set as default formatter', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => AUTOPEP8_EXTENSION); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(isExtensionEnabledStub.notCalled); + }); + + test('Formatting provider is set to black, and black extension is set as default formatter', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'black'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => BLACK_EXTENSION); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue(isExtensionEnabledStub.notCalled); + }); + + test('Prompt: user selects do not show', async () => { + persistState.setup((p) => p.value).returns(() => false); + persistState + .setup((p) => p.updateValue(true)) + .returns(() => Promise.resolve()) + .verifiable(TypeMoq.Times.atLeastOnce()); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns(undefined); + + showInformationMessageStub.resolves(Common.doNotShowAgain); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.installAutopep8FormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + persistState.verifyAll(); + }); + + test('Prompt (autopep8): user selects Autopep8', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns(undefined); + + showInformationMessageStub.resolves('Autopep8'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.installAutopep8FormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + installFormatterExtensionStub.calledWith(AUTOPEP8_EXTENSION, undefined), + 'installFormatterExtension should be called', + ); + }); + + test('Prompt (autopep8): user selects Black', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns(undefined); + + showInformationMessageStub.resolves('Black'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.installAutopep8FormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + installFormatterExtensionStub.calledWith(BLACK_EXTENSION, undefined), + 'installFormatterExtension should be called', + ); + }); + + test('Prompt (black): user selects Autopep8', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'black'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns(undefined); + + showInformationMessageStub.resolves('Autopep8'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.installBlackFormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + installFormatterExtensionStub.calledWith(AUTOPEP8_EXTENSION, undefined), + 'installFormatterExtension should be called', + ); + }); + + test('Prompt (black): user selects Black', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'black'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns(undefined); + + showInformationMessageStub.resolves('Black'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.installBlackFormatterPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + installFormatterExtensionStub.calledWith(BLACK_EXTENSION, undefined), + 'installFormatterExtension should be called', + ); + }); + + test('Prompt: Black and Autopep8 installed user selects Black as default', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'black'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns({}); + + showInformationMessageStub.resolves('Black'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.selectMultipleFormattersPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + updateDefaultFormatterStub.calledWith(BLACK_EXTENSION, undefined), + 'updateDefaultFormatter should be called', + ); + }); + + test('Prompt: Black and Autopep8 installed user selects Autopep8 as default', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.returns({}); + + showInformationMessageStub.resolves('Autopep8'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.selectMultipleFormattersPrompt, + 'Black', + 'Autopep8', + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + updateDefaultFormatterStub.calledWith(AUTOPEP8_EXTENSION, undefined), + 'updateDefaultFormatter should be called', + ); + }); + + test('Prompt: Black installed user selects Black as default', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'black'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.callsFake((extensionId) => { + if (extensionId === BLACK_EXTENSION) { + return {}; + } + return undefined; + }); + + showInformationMessageStub.resolves('Black'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.selectBlackFormatterPrompt, + Common.bannerLabelYes, + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + updateDefaultFormatterStub.calledWith(BLACK_EXTENSION, undefined), + 'updateDefaultFormatter should be called', + ); + }); + + test('Prompt: Autopep8 installed user selects Autopep8 as default', async () => { + persistState.setup((p) => p.value).returns(() => false); + pythonConfig.setup((p) => p.get('formatting.provider', TypeMoq.It.isAny())).returns(() => 'autopep8'); + editorConfig.setup((p) => p.get('defaultFormatter', TypeMoq.It.isAny())).returns(() => ''); + isExtensionEnabledStub.callsFake((extensionId) => { + if (extensionId === AUTOPEP8_EXTENSION) { + return {}; + } + return undefined; + }); + + showInformationMessageStub.resolves('Autopep8'); + + await prompt.showInstallFormatterPrompt(); + assert.isTrue( + showInformationMessageStub.calledWith( + ToolsExtensions.selectAutopep8FormatterPrompt, + Common.bannerLabelYes, + Common.doNotShowAgain, + ), + 'showInformationMessage should be called', + ); + assert.isTrue( + updateDefaultFormatterStub.calledWith(AUTOPEP8_EXTENSION, undefined), + 'updateDefaultFormatter should be called', + ); + }); +}); diff --git a/extensions/positron-python/src/test/providers/terminal.unit.test.ts b/extensions/positron-python/src/test/providers/terminal.unit.test.ts index 9a62b560dc99..603c0710f8c5 100644 --- a/extensions/positron-python/src/test/providers/terminal.unit.test.ts +++ b/extensions/positron-python/src/test/providers/terminal.unit.test.ts @@ -7,9 +7,15 @@ import * as TypeMoq from 'typemoq'; import { Disposable, Terminal, Uri } from 'vscode'; import { IActiveResourceService, ICommandManager, IWorkspaceService } from '../../client/common/application/types'; import { Commands } from '../../client/common/constants'; +import { TerminalEnvVarActivation } from '../../client/common/experiments/groups'; import { TerminalService } from '../../client/common/terminal/service'; import { ITerminalActivator, ITerminalServiceFactory } from '../../client/common/terminal/types'; -import { IConfigurationService, IPythonSettings, ITerminalSettings } from '../../client/common/types'; +import { + IConfigurationService, + IExperimentService, + IPythonSettings, + ITerminalSettings, +} from '../../client/common/types'; import { IServiceContainer } from '../../client/ioc/types'; import { TerminalProvider } from '../../client/providers/terminalProvider'; @@ -18,13 +24,17 @@ suite('Terminal Provider', () => { let commandManager: TypeMoq.IMock; let workspace: TypeMoq.IMock; let activeResourceService: TypeMoq.IMock; + let experimentService: TypeMoq.IMock; let terminalProvider: TerminalProvider; const resource = Uri.parse('a'); setup(() => { serviceContainer = TypeMoq.Mock.ofType(); commandManager = TypeMoq.Mock.ofType(); + experimentService = TypeMoq.Mock.ofType(); + experimentService.setup((e) => e.inExperimentSync(TerminalEnvVarActivation.experiment)).returns(() => false); activeResourceService = TypeMoq.Mock.ofType(); workspace = TypeMoq.Mock.ofType(); + serviceContainer.setup((c) => c.get(IExperimentService)).returns(() => experimentService.object); serviceContainer.setup((c) => c.get(ICommandManager)).returns(() => commandManager.object); serviceContainer.setup((c) => c.get(IWorkspaceService)).returns(() => workspace.object); serviceContainer.setup((c) => c.get(IActiveResourceService)).returns(() => activeResourceService.object); diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/common.ts b/extensions/positron-python/src/test/pythonEnvironments/base/common.ts index 7276560d5e71..847d6e752273 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/common.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/common.ts @@ -104,7 +104,7 @@ export class SimpleLocator extends Locator { constructor( private envs: I[], public callbacks: { - resolve?: null | ((env: PythonEnvInfo) => Promise); + resolve?: null | ((env: PythonEnvInfo | string) => Promise); before?(): Promise; after?(): Promise; onUpdated?: Event | ProgressNotificationEvent>; @@ -112,6 +112,7 @@ export class SimpleLocator extends Locator { afterEach?(e: I): Promise; onQuery?(query: PythonLocatorQuery | undefined, envs: I[]): Promise; } = {}, + private options?: { resolveAsString?: boolean }, ) { super(); } @@ -172,7 +173,7 @@ export class SimpleLocator extends Locator { if (this.callbacks?.resolve === null) { return undefined; } - return this.callbacks.resolve(envInfo); + return this.callbacks.resolve(this.options?.resolveAsString ? env : envInfo); } } diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts index ac7157365f02..f48d91cf24ae 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. @@ -22,13 +23,31 @@ import * as externalDependencies from '../../../../../client/pythonEnvironments/ import { noop } from '../../../../core'; import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; import { SimpleLocator } from '../../common'; -import { assertEnvEqual, assertEnvsEqual } from '../envTestUtils'; +import { assertEnvEqual, assertEnvsEqual, createFile, deleteFile } from '../envTestUtils'; +import { OSType, getOSType } from '../../../../common'; suite('Python envs locator - Environments Collection', async () => { let collectionService: EnvsCollectionService; let storage: PythonEnvInfo[]; const updatedName = 'updatedName'; + const pathToCondaPython = getOSType() === OSType.Windows ? 'python.exe' : path.join('bin', 'python'); + const condaEnvWithoutPython = createEnv( + 'python', + undefined, + undefined, + path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'), + PythonEnvKind.Conda, + path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython), + ); + const condaEnvWithPython = createEnv( + path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython), + undefined, + undefined, + path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'), + PythonEnvKind.Conda, + path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython', pathToCondaPython), + ); function applyChangeEventToEnvList(envs: PythonEnvInfo[], event: PythonEnvCollectionChangedEvent) { const env = event.old ?? event.new; @@ -49,8 +68,17 @@ suite('Python envs locator - Environments Collection', async () => { return envs; } - function createEnv(executable: string, searchLocation?: Uri, name?: string, location?: string) { - return buildEnvInfo({ executable, searchLocation, name, location }); + function createEnv( + executable: string, + searchLocation?: Uri, + name?: string, + location?: string, + kind?: PythonEnvKind, + id?: string, + ) { + const env = buildEnvInfo({ executable, searchLocation, name, location, kind }); + env.id = id ?? env.id; + return env; } function getLocatorEnvs() { @@ -77,12 +105,7 @@ suite('Python envs locator - Environments Collection', async () => { path.join(TEST_LAYOUT_ROOT, 'pipenv', 'project1', '.venv', 'Scripts', 'python.exe'), Uri.file(TEST_LAYOUT_ROOT), ); - const envCached3 = createEnv( - 'python', - undefined, - undefined, - path.join(TEST_LAYOUT_ROOT, 'envsWithoutPython', 'condaLackingPython'), - ); + const envCached3 = condaEnvWithoutPython; return [cachedEnvForWorkspace, envCached1, envCached2, envCached3]; } @@ -123,7 +146,8 @@ suite('Python envs locator - Environments Collection', async () => { collectionService = new EnvsCollectionService(cache, parentLocator); }); - teardown(() => { + teardown(async () => { + await deleteFile(condaEnvWithPython.executable.filename); // Restore to the original state sinon.restore(); }); @@ -404,7 +428,7 @@ suite('Python envs locator - Environments Collection', async () => { env.executable.mtime = 100; sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 }); const parentLocator = new SimpleLocator([], { - resolve: async (e: PythonEnvInfo) => { + resolve: async (e: any) => { if (env.executable.filename === e.executable.filename) { return resolvedViaLocator; } @@ -434,7 +458,7 @@ suite('Python envs locator - Environments Collection', async () => { waitDeferred.resolve(); await deferred.promise; }, - resolve: async (e: PythonEnvInfo) => { + resolve: async (e: any) => { if (env.executable.filename === e.executable.filename) { return resolvedViaLocator; } @@ -464,7 +488,7 @@ suite('Python envs locator - Environments Collection', async () => { env.executable.mtime = 90; sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 }); const parentLocator = new SimpleLocator([], { - resolve: async (e: PythonEnvInfo) => { + resolve: async (e: any) => { if (env.executable.filename === e.executable.filename) { return resolvedViaLocator; } @@ -483,7 +507,7 @@ suite('Python envs locator - Environments Collection', async () => { test('resolveEnv() adds env to cache after resolving using downstream locator', async () => { const resolvedViaLocator = buildEnvInfo({ executable: 'Resolved via locator' }); const parentLocator = new SimpleLocator([], { - resolve: async (e: PythonEnvInfo) => { + resolve: async (e: any) => { if (resolvedViaLocator.executable.filename === e.executable.filename) { return resolvedViaLocator; } @@ -500,6 +524,49 @@ suite('Python envs locator - Environments Collection', async () => { assertEnvsEqual(envs, [resolved]); }); + test('resolveEnv() uses underlying locator once conda envs without python get a python installed', async () => { + const cachedEnvs = [condaEnvWithoutPython]; + const parentLocator = new SimpleLocator( + [], + { + resolve: async (e) => { + if (condaEnvWithoutPython.location === (e as string)) { + return condaEnvWithPython; + } + return undefined; + }, + }, + { resolveAsString: true }, + ); + const cache = await createCollectionCache({ + get: () => cachedEnvs, + store: async () => noop(), + }); + collectionService = new EnvsCollectionService(cache, parentLocator); + let resolved = await collectionService.resolveEnv(condaEnvWithoutPython.location); + assertEnvEqual(resolved, condaEnvWithoutPython); // Ensure cache is used to resolve such envs. + + condaEnvWithPython.executable.ctime = 100; + condaEnvWithPython.executable.mtime = 100; + sinon.stub(externalDependencies, 'getFileInfo').resolves({ ctime: 100, mtime: 100 }); + + const events: PythonEnvCollectionChangedEvent[] = []; + collectionService.onChanged((e) => { + events.push(e); + }); + + await createFile(condaEnvWithPython.executable.filename); // Install Python into the env + + resolved = await collectionService.resolveEnv(condaEnvWithoutPython.location); + assertEnvEqual(resolved, condaEnvWithPython); // Ensure it resolves latest info. + + // Verify conda env without python in cache is replaced with updated info. + const envs = collectionService.getEnvs(); + assertEnvsEqual(envs, [condaEnvWithPython]); + + expect(events.length).to.equal(1, 'Update event should be fired'); + }); + test('Ensure events from downstream locators do not trigger new refreshes if a refresh is already scheduled', async () => { const refreshDeferred = createDeferred(); let refreshCount = 0; diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/envTestUtils.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/envTestUtils.ts index 64f8f9558d3c..d1099ee4f840 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/envTestUtils.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/envTestUtils.ts @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as fsapi from 'fs-extra'; import * as assert from 'assert'; import { exec } from 'child_process'; -import { zip } from 'lodash'; +import { cloneDeep, zip } from 'lodash'; import { promisify } from 'util'; import { PythonEnvInfo, PythonVersion, UNKNOWN_PYTHON_VERSION } from '../../../../client/pythonEnvironments/base/info'; import { getEmptyVersion } from '../../../../client/pythonEnvironments/base/info/pythonVersion'; @@ -40,17 +41,30 @@ export function assertVersionsEqual(actual: PythonVersion | undefined, expected: assert.deepStrictEqual(actual, expected); } +export async function createFile(filename: string, text = ''): Promise { + await fsapi.writeFile(filename, text); + return filename; +} + +export async function deleteFile(filename: string): Promise { + await fsapi.remove(filename); +} + export function assertEnvEqual(actual: PythonEnvInfo | undefined, expected: PythonEnvInfo | undefined): void { assert.notStrictEqual(actual, undefined); assert.notStrictEqual(expected, undefined); if (actual) { + // Make sure to clone so we do not alter the original object + actual = cloneDeep(actual); + expected = cloneDeep(expected); // No need to match these, so reset them actual.executable.ctime = -1; actual.executable.mtime = -1; - actual.version = normalizeVersion(actual.version); if (expected) { + expected.executable.ctime = -1; + expected.executable.mtime = -1; expected.version = normalizeVersion(expected.version); delete expected.id; } diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/activestateLocator.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/activestateLocator.unit.test.ts index 5bdbd22def0f..bf86db883433 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/activestateLocator.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/activestateLocator.unit.test.ts @@ -7,7 +7,7 @@ import * as fsapi from 'fs-extra'; import { PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info'; import * as externalDependencies from '../../../../../client/pythonEnvironments/common/externalDependencies'; import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils'; -import { ActiveStateLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/activestateLocator'; +import { ActiveStateLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/activeStateLocator'; import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; import { assertBasicEnvsEqual } from '../envTestUtils'; import { ExecutionResult } from '../../../../../client/common/process/types'; diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.unit.test.ts index 3847869f5a2b..62339df7e144 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/macDefaultLocator.unit.test.ts @@ -4,7 +4,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as osUtils from '../../../../../client/common/utils/platform'; -import { isMacDefaultPythonPath } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/macDefaultLocator'; +import { isMacDefaultPythonPath } from '../../../../../client/pythonEnvironments/common/environmentManagers/macDefault'; suite('isMacDefaultPythonPath', () => { let getOSTypeStub: sinon.SinonStub; @@ -18,8 +18,6 @@ suite('isMacDefaultPythonPath', () => { }); const testCases: { path: string; os: osUtils.OSType; expected: boolean }[] = [ - { path: 'python', os: osUtils.OSType.OSX, expected: true }, - { path: 'python', os: osUtils.OSType.Windows, expected: false }, { path: '/usr/bin/python', os: osUtils.OSType.OSX, expected: true }, { path: '/usr/bin/python', os: osUtils.OSType.Linux, expected: false }, { path: '/usr/bin/python2', os: osUtils.OSType.OSX, expected: true }, diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.unit.test.ts index 89ab4402b3b6..7a9a2bc6475d 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.unit.test.ts @@ -14,7 +14,7 @@ import { PosixKnownPathsLocator } from '../../../../../client/pythonEnvironments import { createBasicEnv } from '../../common'; import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; import { assertBasicEnvsEqual } from '../envTestUtils'; -import { isMacDefaultPythonPath } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/macDefaultLocator'; +import { isMacDefaultPythonPath } from '../../../../../client/pythonEnvironments/common/environmentManagers/macDefault'; suite('Posix Known Path Locator', () => { let getPathEnvVar: sinon.SinonStub; diff --git a/extensions/positron-python/src/test/pythonEnvironments/common/envlayouts/envsWithoutPython/condaLackingPython/bin/dummy b/extensions/positron-python/src/test/pythonEnvironments/common/envlayouts/envsWithoutPython/condaLackingPython/bin/dummy new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts new file mode 100644 index 000000000000..1286ac44d58d --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +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 { ConfigurationTarget, Uri } from 'vscode'; +import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../../client/common/types'; +import * as commandApis from '../../../client/common/vscodeApis/commandApis'; +import { IInterpreterQuickPick } from '../../../client/interpreter/configuration/types'; +import { registerCreateEnvironmentFeatures } from '../../../client/pythonEnvironments/creation/createEnvApi'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; +import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; + +chaiUse(chaiAsPromised); + +suite('Create Environment APIs', () => { + let registerCommandStub: sinon.SinonStub; + let showQuickPickStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + let interpreterQuickPick: typemoq.IMock; + let interpreterPathService: typemoq.IMock; + let pathUtils: typemoq.IMock; + + setup(() => { + showQuickPickStub = sinon.stub(windowApis, 'showQuickPick'); + showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage'); + + registerCommandStub = sinon.stub(commandApis, 'registerCommand'); + interpreterQuickPick = typemoq.Mock.ofType(); + interpreterPathService = typemoq.Mock.ofType(); + pathUtils = typemoq.Mock.ofType(); + + registerCommandStub.callsFake((_command: string, _callback: (...args: any[]) => any) => ({ + dispose: () => { + // Do nothing + }, + })); + + pathUtils.setup((p) => p.getDisplayName(typemoq.It.isAny())).returns(() => 'test'); + + registerCreateEnvironmentFeatures( + disposables, + interpreterQuickPick.object, + interpreterPathService.object, + pathUtils.object, + ); + }); + teardown(() => { + disposables.forEach((d) => d.dispose()); + sinon.restore(); + }); + + [true, false].forEach((selectEnvironment) => { + test(`Set environment selectEnvironment == ${selectEnvironment}`, 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'); + provider + .setup((p) => p.createEnvironment(typemoq.It.isAny())) + .returns(() => + Promise.resolve({ + path: '/path/to/env', + uri: Uri.file('/path/to/env'), + }), + ); + provider.setup((p) => (p as any).then).returns(() => undefined); + + showQuickPickStub.resolves(provider.object); + + interpreterPathService + .setup((p) => + p.update( + typemoq.It.isAny(), + ConfigurationTarget.WorkspaceFolder, + typemoq.It.isValue('/path/to/env'), + ), + ) + .returns(() => Promise.resolve()) + .verifiable(selectEnvironment ? typemoq.Times.once() : typemoq.Times.never()); + + await handleCreateEnvironmentCommand([provider.object], { selectEnvironment }); + + assert.ok(showQuickPickStub.calledOnce); + assert.ok(selectEnvironment ? showInformationMessageStub.calledOnce : showInformationMessageStub.notCalled); + interpreterPathService.verifyAll(); + }); + }); +}); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts index f5267aa634cb..db5eb351211e 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts @@ -76,7 +76,7 @@ suite('Conda Creation provider tests', () => { }); pickPythonVersionStub.resolves(undefined); - assert.isUndefined(await condaProvider.createEnvironment()); + await assert.isRejected(condaProvider.createEnvironment()); }); test('Create conda environment', async () => { diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaUtils.unit.test.ts new file mode 100644 index 000000000000..3f115f9f58ed --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaUtils.unit.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { CancellationTokenSource } from 'vscode'; +import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; +import { pickPythonVersion } from '../../../../client/pythonEnvironments/creation/provider/condaUtils'; + +suite('Conda Utils test', () => { + let showQuickPickWithBackStub: sinon.SinonStub; + + setup(() => { + showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No version selected or user pressed escape', async () => { + showQuickPickWithBackStub.resolves(undefined); + + const actual = await pickPythonVersion(); + assert.isUndefined(actual); + }); + + test('User selected a version', async () => { + showQuickPickWithBackStub.resolves({ label: 'Python', description: '3.10' }); + + const actual = await pickPythonVersion(); + assert.equal(actual, '3.10'); + }); + + test('With cancellation', async () => { + const source = new CancellationTokenSource(); + + showQuickPickWithBackStub.callsFake(() => { + source.cancel(); + }); + + const actual = await pickPythonVersion(source.token); + assert.isUndefined(actual); + }); +}); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts index a0d030853717..360bb43fad4b 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts @@ -80,7 +80,7 @@ suite('Venv Utils test', () => { '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', ); - showQuickPickWithBackStub.rejects(windowApis.MultiStepAction.Cancel); + showQuickPickWithBackStub.resolves(undefined); await assert.isRejected(pickPackagesToInstall(workspace1)); assert.isTrue( @@ -215,7 +215,7 @@ suite('Venv Utils test', () => { return Promise.resolve([]); }); - showQuickPickWithBackStub.rejects(windowApis.MultiStepAction.Cancel); + showQuickPickWithBackStub.resolves(undefined); await assert.isRejected(pickPackagesToInstall(workspace1)); assert.isTrue( diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts new file mode 100644 index 000000000000..3f19aa5775b3 --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +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 { TextDocument, TextDocumentChangeEvent } from 'vscode'; +import * as cmdApis from '../../../client/common/vscodeApis/commandApis'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import { IDisposableRegistry } from '../../../client/common/types'; +import { registerPyProjectTomlCreateEnvFeatures } from '../../../client/pythonEnvironments/creation/pyprojectTomlCreateEnv'; + +chaiUse(chaiAsPromised); + +class FakeDisposable { + public dispose() { + // Do nothing + } +} + +function getInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns( + () => + '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + return pyprojectToml; +} + +function getNonInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns(() => '[project]\nname = "spam"\nversion = "2020.0.0"\n'); + return pyprojectToml; +} + +function getSomeFile(): typemoq.IMock { + const someFilePath = 'something.py'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'print("Hello World")'); + return someFile; +} + +suite('PyProject.toml Create Env Features', () => { + let executeCommandStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + let getOpenTextDocumentsStub: sinon.SinonStub; + let onDidOpenTextDocumentStub: sinon.SinonStub; + let onDidChangeTextDocumentStub: sinon.SinonStub; + + setup(() => { + executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); + getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments'); + onDidOpenTextDocumentStub = sinon.stub(workspaceApis, 'onDidOpenTextDocument'); + onDidChangeTextDocumentStub = sinon.stub(workspaceApis, 'onDidChangeTextDocument'); + + onDidOpenTextDocumentStub.returns(new FakeDisposable()); + onDidChangeTextDocumentStub.returns(new FakeDisposable()); + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('Installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getNonInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file open in the editor on extension activate', async () => { + const someFile = getSomeFile(); + getOpenTextDocumentsStub.returns([someFile.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.notCalled); + }); + + test('Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(someFile.object); + + assert.ok(executeCommandStub.notCalled); + }); + + test('Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: someFile.object, reason: undefined }); + + assert.ok(executeCommandStub.notCalled); + }); +}); diff --git a/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts b/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts index dfe9e8ce5e99..b8b7d5c55130 100644 --- a/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts +++ b/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts @@ -28,6 +28,7 @@ import { LaunchOptions } from '../../../client/testing/common/types'; import { ITestingSettings } from '../../../client/testing/configuration/types'; import { TestProvider } from '../../../client/testing/types'; import { isOs, OSType } from '../../common'; +import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; use(chaiAsPromised); @@ -39,12 +40,18 @@ suite('Unit Tests - Debug Launcher', () => { let settings: TypeMoq.IMock; let debugEnvHelper: TypeMoq.IMock; let interpreterService: TypeMoq.IMock; + let environmentActivationService: TypeMoq.IMock; let getWorkspaceFolderStub: sinon.SinonStub; let getWorkspaceFoldersStub: sinon.SinonStub; let pathExistsStub: sinon.SinonStub; let readFileStub: sinon.SinonStub; + const envVars = { FOO: 'BAR' }; setup(async () => { + environmentActivationService = TypeMoq.Mock.ofType(); + environmentActivationService + .setup((e) => e.getActivatedEnvironmentVariables(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(envVars)); interpreterService = TypeMoq.Mock.ofType(); serviceContainer = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); const configService = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); @@ -94,6 +101,7 @@ suite('Unit Tests - Debug Launcher', () => { configService, debugEnvHelper.object, interpreterService.object, + environmentActivationService.object, ); } function setupDebugManager( @@ -110,7 +118,7 @@ suite('Unit Tests - Debug Launcher', () => { expected.args = debugArgs; debugEnvHelper - .setup((d) => d.getEnvironmentVariables(TypeMoq.It.isAny())) + .setup((x) => x.getEnvironmentVariables(TypeMoq.It.isAny(), TypeMoq.It.isAny())) .returns(() => Promise.resolve(expected.env)); debugService @@ -205,6 +213,9 @@ suite('Unit Tests - Debug Launcher', () => { if (!expected.python) { expected.python = 'python'; } + if (!expected.clientOS) { + expected.clientOS = isOs(OSType.Windows) ? 'windows' : 'unix'; + } if (!expected.debugAdapterPython) { expected.debugAdapterPython = 'python'; } diff --git a/extensions/positron-python/types/vscode.proposed.envShellEvent.d.ts b/extensions/positron-python/types/vscode.proposed.envShellEvent.d.ts new file mode 100644 index 000000000000..8fed971ef711 --- /dev/null +++ b/extensions/positron-python/types/vscode.proposed.envShellEvent.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // See https://github.com/microsoft/vscode/issues/160694 + export namespace env { + + /** + * An {@link Event} which fires when the default shell changes. + */ + export const onDidChangeShell: Event; + } +} diff --git a/extensions/positron-python/yarn.lock b/extensions/positron-python/yarn.lock index 578ec82cbf25..d0fc09261a32 100644 --- a/extensions/positron-python/yarn.lock +++ b/extensions/positron-python/yarn.lock @@ -590,10 +590,12 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== -"@types/caseless@*": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" - integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== +"@types/bent@^7.3.0": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@types/bent/-/bent-7.3.3.tgz#b8daa06e72219045b3f67f968d590d3df3875d96" + integrity sha512-5NEIhVzHiZ6wMjFBmJ3gwjxwGug6amMoAn93rtDBttwrODxm+bt63u+MJA7H9NGGM4X1m73sJrAxDapktl036Q== + dependencies: + "@types/node" "*" "@types/chai-arrays@^2.0.0": version "2.0.0" @@ -737,16 +739,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.36.tgz#c414052cb9d43fab67d679d5f3c641be911f5835" integrity sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ== -"@types/request@^2.47.0": - version "2.48.8" - resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c" - integrity sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ== - dependencies: - "@types/caseless" "*" - "@types/node" "*" - "@types/tough-cookie" "*" - form-data "^2.5.0" - "@types/semver@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" @@ -789,10 +781,10 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== -"@types/vscode@1.74.0": - version "1.74.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.74.0.tgz#4adc21b4e7f527b893de3418c21a91f1e503bdcd" - integrity sha512-LyeCIU3jb9d38w0MXFwta9r0Jx23ugujkAxdwLTNCyspdZTKUc43t7ppPbCiPoQ/Ivd/pnDFZrb4hWd45wrsgA== +"@types/vscode@^1.75.0": + version "1.77.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.77.0.tgz#f92f15a636abc9ef562f44dd8af6766aefedb445" + integrity sha512-MWFN5R7a33n8eJZJmdVlifjig3LWUNRrPeO1xemIcZ0ae0TEQuRc7G2xV0LUX78RZFECY1plYBn+dP/Acc3L0Q== "@types/which@^2.0.1": version "2.0.1" @@ -1110,7 +1102,7 @@ ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1401,18 +1393,6 @@ asn1.js@^5.2.0: minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - assert@^1.1.1: version "1.5.0" resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" @@ -1515,16 +1495,6 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== - -aws4@^1.8.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" - integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== - axe-core@^4.6.2: version "4.6.3" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece" @@ -1598,12 +1568,14 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== +bent@^7.3.12: + version "7.3.12" + resolved "https://registry.yarnpkg.com/bent/-/bent-7.3.12.tgz#e0a2775d4425e7674c64b78b242af4f49da6b035" + integrity sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w== dependencies: - tweetnacl "^0.14.3" + bytesish "^0.4.1" + caseless "~0.12.0" + is-stream "^2.0.0" big-integer@^1.6.17: version "1.6.51" @@ -1874,6 +1846,11 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== +bytesish@^0.4.1: + version "0.4.4" + resolved "https://registry.yarnpkg.com/bytesish/-/bytesish-0.4.4.tgz#f3b535a0f1153747427aee27256748cff92347e6" + integrity sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ== + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -2238,7 +2215,7 @@ colorette@^2.0.14: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== -combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2355,11 +2332,6 @@ core-js@^2.4.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -2472,13 +2444,6 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== - dependencies: - assert-plus "^1.0.0" - debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -2881,14 +2846,6 @@ each-props@^1.3.2: is-plain-object "^2.0.1" object.defaults "^1.1.0" -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - electron-to-chromium@^1.4.284: version "1.4.292" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.292.tgz#e3a3dca3780c8ce01e2c1866b5ec2fbe31c423e3" @@ -3429,7 +3386,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@^3.0.0, extend@~3.0.2: +extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3448,16 +3405,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - fancy-log@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" @@ -3723,11 +3670,6 @@ foreground-child@^2.0.0: cross-spawn "^7.0.0" signal-exit "^3.0.2" -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== - form-data@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" @@ -3746,15 +3688,6 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -3929,13 +3862,6 @@ get-value@^2.0.3, get-value@^2.0.6: resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA== -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== - dependencies: - assert-plus "^1.0.0" - github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -4179,19 +4105,6 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" @@ -4377,15 +4290,6 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" @@ -4847,7 +4751,7 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.3, is-typed-array@^1.1.9: gopd "^1.0.1" has-tostringtag "^1.0.0" -is-typedarray@^1.0.0, is-typedarray@~1.0.0: +is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== @@ -4931,11 +4835,6 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== - istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" @@ -5033,11 +4932,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -5063,17 +4957,12 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== -json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== @@ -5104,16 +4993,6 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsprim@^1.2.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" - integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz#76b3e6e6cece5c69d49a5792c3d01bd1a0cdc7ea" @@ -5526,7 +5405,7 @@ mime-db@1.52.0, mime-db@^1.28.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.19: +mime-types@^2.1.12, mime-types@^2.1.27: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -5967,11 +5846,6 @@ nyc@^15.0.0: test-exclude "^6.0.0" yargs "^15.0.2" -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -6405,11 +6279,6 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6557,11 +6426,6 @@ propagate@^1.0.0: resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709" integrity sha512-T/rqCJJaIPYObiLSmaDsIf4PGA7y+pkgYFHmwoXQyOHiDDSO1YCxcztNiRBmV4EZha4QIbID3vQIHkqKu5k0Xg== -psl@^1.1.28: - version "1.9.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== - public-encrypt@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" @@ -6621,11 +6485,6 @@ qs@^6.5.1, qs@^6.9.1: dependencies: side-channel "^1.0.4" -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - query-string@^5.0.1: version "5.1.1" resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" @@ -6846,39 +6705,6 @@ replace-homedir@^1.0.0: is-absolute "^1.0.0" remove-trailing-separator "^1.1.0" -request-progress@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" - integrity sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg== - dependencies: - throttleit "^1.0.0" - -request@^2.87.0: - version "2.88.2" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" - integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -7052,7 +6878,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7395,21 +7221,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -sshpk@^1.7.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - stack-chain@^1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" @@ -7737,11 +7548,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== -throttleit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" - integrity sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g== - through2-filter@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" @@ -7869,14 +7675,6 @@ totalist@^1.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - "traverse@>=0.3.0 <0.4": version "0.3.9" resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" @@ -7983,11 +7781,6 @@ tunnel@0.0.6: resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -8265,11 +8058,6 @@ util@^0.12.0, util@^0.12.4: is-typed-array "^1.1.3" which-typed-array "^1.1.2" -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" @@ -8305,15 +8093,6 @@ value-or-function@^3.0.0: resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813" integrity sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg== -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - vinyl-fs@^3.0.0, vinyl-fs@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-3.0.3.tgz#c85849405f67428feabbbd5c5dbdd64f47d31bc7" @@ -8573,10 +8352,10 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@^5.70.0: - version "5.75.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.75.0.tgz#1e440468647b2505860e94c9ff3e44d5b582c152" - integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ== +webpack@^5.76.0: + version "5.77.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.77.0.tgz#dea3ad16d7ea6b84aa55fa42f4eac9f30e7eb9b4" + integrity sha512-sbGNjBr5Ya5ss91yzjeJTLKyfiwo5C628AFjEa6WSXcZa4E+F57om3Cc8xLb1Jh0b243AWuSYRf3dn7HVeFQ9Q== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^0.0.51"