diff --git a/package.json b/package.json index 9d1cf544b692..3ec055896aca 100644 --- a/package.json +++ b/package.json @@ -1465,80 +1465,59 @@ ], "languageModelTools": [ { - "name": "get_python_environment_info", + "name": "get_python_environment_details", "displayName": "Get Python Environment Info", - "userDescription": "%python.languageModelTools.get_python_environment_info.userDescription%", - "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Environment (conda, venv, etec), 2. Version of Python, 3. List of all installed packages with their versions. ", + "userDescription": "%python.languageModelTools.get_python_environment_details.userDescription%", + "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Environment (conda, venv, etec), 2. Version of Python, 3. List of all installed packages with their versions. ALWAYS call configure_python_environment before using this tool.", "toolReferenceName": "pythonGetEnvironmentInfo", "tags": [ - "ms-python.python" + "enable_other_tool_configure_python_environment" ], - "icon": "$(files)", + "icon": "$(snake)", "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { "resourcePath": { - "type": "string" + "type": "string", + "description": "The path to the Python file or workspace to get the environment information for." } }, - "description": "The path to the Python file or workspace to get the environment information for.", "required": [] - } + }, + "when": "!pythonEnvExtensionInstalled" }, { - "name": "get_python_executable", + "name": "get_python_executable_details", "displayName": "Get Python Executable", - "userDescription": "%python.languageModelTools.get_python_executable.userDescription%", - "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n -c 'import sys; print(sys.executable)'`.", + "userDescription": "%python.languageModelTools.get_python_executable_details.userDescription%", + "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n -c 'import sys; print(sys.executable)'`. ALWAYS call configure_python_environment before using this tool.", "toolReferenceName": "pythonExecutableCommand", "tags": [ - "ms-python.python" - ], - "icon": "$(files)", - "canBeReferencedInPrompt": true, - "inputSchema": { - "type": "object", - "properties": { - "resourcePath": { - "type": "string" - } - }, - "description": "The path to the Python file or workspace to get the executable information for. If not provided, the current workspace will be used. Where possible pass the path to the file or workspace.", - "required": [] - } - }, - { - "name": "list_python_packages", - "displayName": "List Python Packages", - "userDescription": "%python.languageModelTools.list_python_packages.userDescription%", - "modelDescription": "This tool will retrieve the list of all installed packages installed in a Python Environment for the specified file or workspace. ALWAYS use this tool instead of executing Python command in the terminal to fetch the list of installed packages. WARNING: Packages installed can change over time, hence the list of packages returned by this tool may not be accurate. Use this tool to get the list of installed packages in a Python environment.", - "toolReferenceName": "listPythonPackages", - "tags": [ - "ms-python.python" + "enable_other_tool_configure_python_environment" ], - "icon": "$(files)", + "icon": "$(terminal)", "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { "resourcePath": { - "type": "string" + "type": "string", + "description": "The path to the Python file or workspace to get the executable information for. If not provided, the current workspace will be used. Where possible pass the path to the file or workspace." } }, - "description": "The path to the Python file or workspace to list the packages. If not provided, the current workspace will be used. Where possible pass the path to the file or workspace.", "required": [] }, - "when": "false" + "when": "!pythonEnvExtensionInstalled" }, { - "name": "install_python_package", + "name": "install_python_packages", "displayName": "Install Python Package", - "userDescription": "%python.languageModelTools.python_install_package.userDescription%", - "modelDescription": "Installs Python packages in the given workspace. Use this tool to install packages in the user's chosen environment.", + "userDescription": "%python.languageModelTools.install_python_packages.userDescription%", + "modelDescription": "Installs Python packages in the given workspace. Use this tool to install packages in the user's chosen environment. ALWAYS call configure_python_environment before using this tool.", "toolReferenceName": "pythonInstallPackage", "tags": [ - "ms-python.python" + "enable_other_tool_configure_python_environment" ], "icon": "$(package)", "canBeReferencedInPrompt": true, @@ -1562,6 +1541,27 @@ ] }, "when": "!pythonEnvExtensionInstalled" + }, + { + "name": "configure_python_environment", + "displayName": "Configure Python Environment", + "modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools.", + "userDescription": "%python.languageModelTools.configure_python_environment.userDescription%", + "toolReferenceName": "configurePythonEnvironment", + "tags": [], + "icon": "$(gear)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "resourcePath": { + "type": "string", + "description": "The path to the Python file or workspace for which a Python Environment needs to be configured." + } + }, + "required": [] + }, + "when": "!pythonEnvExtensionInstalled" } ] }, diff --git a/package.nls.json b/package.nls.json index a73deb3554da..b6ba75b332f2 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,9 +1,9 @@ { "python.command.python.startTerminalREPL.title": "Start Terminal REPL", - "python.languageModelTools.get_python_environment_info.userDescription": "Get information for a Python Environment, such as Type, Version, Packages, and more.", - "python.languageModelTools.python_install_package.userDescription": "Installs Python packages in a Python Environment.", - "python.languageModelTools.get_python_executable.userDescription": "Get executable info for a Python Environment", - "python.languageModelTools.list_python_packages.userDescription": "Get a list of all installed packages in a Python Environment.", + "python.languageModelTools.get_python_environment_details.userDescription": "Get information for a Python Environment, such as Type, Version, Packages, and more.", + "python.languageModelTools.install_python_packages.userDescription": "Installs Python packages in a Python Environment.", + "python.languageModelTools.get_python_executable_details.userDescription": "Get executable info for a Python Environment", + "python.languageModelTools.configure_python_environment.userDescription": "Configure a Python Environment for a workspace", "python.command.python.startNativeREPL.title": "Start Native Python REPL", "python.command.python.createEnvironment.title": "Create Environment...", "python.command.python.createNewFile.title": "New Python File", diff --git a/src/client/chat/configurePythonEnvTool.ts b/src/client/chat/configurePythonEnvTool.ts new file mode 100644 index 000000000000..1a22fed83140 --- /dev/null +++ b/src/client/chat/configurePythonEnvTool.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + CancellationToken, + l10n, + LanguageModelTextPart, + LanguageModelTool, + LanguageModelToolInvocationOptions, + LanguageModelToolInvocationPrepareOptions, + LanguageModelToolResult, + PreparedToolInvocation, + Uri, + workspace, + commands, + QuickPickItem, +} from 'vscode'; +import { PythonExtension, ResolvedEnvironment } from '../api/types'; +import { IServiceContainer } from '../ioc/types'; +import { ICodeExecutionService } from '../terminals/types'; +import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; +import { getEnvironmentDetails, raceCancellationError } from './utils'; +import { resolveFilePath } from './utils'; +import { IRecommendedEnvironmentService } from '../interpreter/configuration/types'; +import { ITerminalHelper } from '../common/terminal/types'; +import { raceTimeout } from '../common/utils/async'; +import { Commands, Octicons } from '../common/constants'; +import { CreateEnvironmentResult } from '../pythonEnvironments/creation/proposed.createEnvApis'; +import { IInterpreterPathService } from '../common/types'; +import { DisposableStore } from '../common/utils/resourceLifecycle'; +import { Common, InterpreterQuickPickList } from '../common/utils/localize'; +import { QuickPickItemKind } from '../../test/mocks/vsc'; +import { showQuickPick } from '../common/vscodeApis/windowApis'; +import { SelectEnvironmentResult } from '../interpreter/configuration/interpreterSelector/commands/setInterpreter'; + +export interface IResourceReference { + resourcePath?: string; +} + +let _environmentConfigured = false; + +export class ConfigurePythonEnvTool implements LanguageModelTool { + private readonly terminalExecutionService: TerminalCodeExecutionProvider; + private readonly terminalHelper: ITerminalHelper; + private readonly recommendedEnvService: IRecommendedEnvironmentService; + public static readonly toolName = 'configure_python_environment'; + constructor( + private readonly api: PythonExtension['environments'], + private readonly serviceContainer: IServiceContainer, + ) { + this.terminalExecutionService = this.serviceContainer.get( + ICodeExecutionService, + 'standard', + ); + this.terminalHelper = this.serviceContainer.get(ITerminalHelper); + this.recommendedEnvService = this.serviceContainer.get( + IRecommendedEnvironmentService, + ); + } + /** + * Invokes the tool to get the information about the Python environment. + * @param options - The invocation options containing the file path. + * @param token - The cancellation token. + * @returns The result containing the information about the Python environment or an error message. + */ + async invoke( + options: LanguageModelToolInvocationOptions, + token: CancellationToken, + ): Promise { + const resource = resolveFilePath(options.input.resourcePath); + const recommededEnv = await this.recommendedEnvService.getRecommededEnvironment(resource); + // Already selected workspace env, hence nothing to do. + if (recommededEnv?.reason === 'workspaceUserSelected' && workspace.workspaceFolders?.length) { + return await getEnvDetailsForResponse( + recommededEnv.environment, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } + // No workspace folders, and the user selected a global environment. + if (recommededEnv?.reason === 'globalUserSelected' && !workspace.workspaceFolders?.length) { + return await getEnvDetailsForResponse( + recommededEnv.environment, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } + + if (!workspace.workspaceFolders?.length) { + const selected = await Promise.resolve(commands.executeCommand(Commands.Set_Interpreter)); + const env = await this.api.resolveEnvironment(this.api.getActiveEnvironmentPath(resource)); + if (selected && env) { + return await getEnvDetailsForResponse( + env, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } + return new LanguageModelToolResult([ + new LanguageModelTextPart('User did not select a Python environment.'), + ]); + } + + const selected = await showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer); + const env = await this.api.resolveEnvironment(this.api.getActiveEnvironmentPath(resource)); + if (selected && env) { + return await getEnvDetailsForResponse( + env, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } + return new LanguageModelToolResult([ + new LanguageModelTextPart('User did not create nor select a Python environment.'), + ]); + } + + async prepareInvocation?( + options: LanguageModelToolInvocationPrepareOptions, + _token: CancellationToken, + ): Promise { + if (_environmentConfigured) { + return {}; + } + const resource = resolveFilePath(options.input.resourcePath); + const recommededEnv = await this.recommendedEnvService.getRecommededEnvironment(resource); + // Already selected workspace env, hence nothing to do. + if (recommededEnv?.reason === 'workspaceUserSelected' && workspace.workspaceFolders?.length) { + return {}; + } + // No workspace folders, and the user selected a global environment. + if (recommededEnv?.reason === 'globalUserSelected' && !workspace.workspaceFolders?.length) { + return {}; + } + + if (!workspace.workspaceFolders?.length) { + return { + confirmationMessages: { + title: l10n.t('Configure a Python Environment?'), + message: l10n.t('You will be prompted to select a Python Environment.'), + }, + }; + } + return { + confirmationMessages: { + title: l10n.t('Configure a Python Environment?'), + message: l10n.t( + [ + 'The recommended option is to create a new Python Environment, providing the benefit of isolating packages from other environments. ', + 'Optionally you could select an existing Python Environment.', + ].join('\n'), + ), + }, + }; + } +} + +async function getEnvDetailsForResponse( + environment: ResolvedEnvironment | undefined, + api: PythonExtension['environments'], + terminalExecutionService: TerminalCodeExecutionProvider, + terminalHelper: ITerminalHelper, + resource: Uri | undefined, + token: CancellationToken, +): Promise { + const envPath = api.getActiveEnvironmentPath(resource); + environment = environment || (await raceCancellationError(api.resolveEnvironment(envPath), token)); + if (!environment || !environment.version) { + throw new Error('No environment found for the provided resource path: ' + resource?.fsPath); + } + const message = await getEnvironmentDetails( + resource, + api, + terminalExecutionService, + terminalHelper, + undefined, + token, + ); + return new LanguageModelToolResult([ + new LanguageModelTextPart(`A Python Environment has been configured. \n` + message), + ]); +} + +async function showCreateAndSelectEnvironmentQuickPick( + uri: Uri | undefined, + serviceContainer: IServiceContainer, +): Promise { + const createLabel = `${Octicons.Add} ${InterpreterQuickPickList.create.label}`; + const selectLabel = l10n.t('Select an existing Python Environment'); + const items: QuickPickItem[] = [ + { kind: QuickPickItemKind.Separator, label: Common.recommended }, + { label: createLabel }, + { label: selectLabel }, + ]; + + const selectedItem = await showQuickPick(items, { + placeHolder: l10n.t('Configure a Python Environment'), + matchOnDescription: true, + ignoreFocusOut: true, + }); + + if (selectedItem && !Array.isArray(selectedItem) && selectedItem.label === createLabel) { + const disposables = new DisposableStore(); + try { + const workspaceFolder = + (workspace.workspaceFolders?.length && uri ? workspace.getWorkspaceFolder(uri) : undefined) || + (workspace.workspaceFolders?.length === 1 ? workspace.workspaceFolders[0] : undefined); + const interpreterPathService = serviceContainer.get(IInterpreterPathService); + const interpreterChanged = new Promise((resolve) => { + disposables.add(interpreterPathService.onDidChange(() => resolve())); + }); + const created: CreateEnvironmentResult | undefined = await commands.executeCommand( + Commands.Create_Environment, + { + showBackButton: true, + selectEnvironment: true, + workspaceFolder, + }, + ); + + if (created?.action === 'Back') { + return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer); + } + if (created?.action === 'Cancel') { + return undefined; + } + if (created?.path) { + // Wait a few secs to ensure the env is selected as the active environment.. + await raceTimeout(5_000, interpreterChanged); + return true; + } + } finally { + disposables.dispose(); + } + } + if (selectedItem && !Array.isArray(selectedItem) && selectedItem.label === selectLabel) { + const result = (await Promise.resolve( + commands.executeCommand(Commands.Set_Interpreter, { hideCreateVenv: true, showBackButton: true }), + )) as SelectEnvironmentResult | undefined; + if (result?.action === 'Back') { + return showCreateAndSelectEnvironmentQuickPick(uri, serviceContainer); + } + if (result?.action === 'Cancel') { + return undefined; + } + if (result?.path) { + return true; + } + } +} diff --git a/src/client/chat/getExecutableTool.ts b/src/client/chat/getExecutableTool.ts index af4ab214419a..350598e5ca36 100644 --- a/src/client/chat/getExecutableTool.ts +++ b/src/client/chat/getExecutableTool.ts @@ -29,7 +29,7 @@ export interface IResourceReference { export class GetExecutableTool implements LanguageModelTool { private readonly terminalExecutionService: TerminalCodeExecutionProvider; private readonly terminalHelper: ITerminalHelper; - public static readonly toolName = 'get_python_executable'; + public static readonly toolName = 'get_python_executable_details'; constructor( private readonly api: PythonExtension['environments'], private readonly serviceContainer: IServiceContainer, diff --git a/src/client/chat/getPythonEnvTool.ts b/src/client/chat/getPythonEnvTool.ts index ef200239af9a..11af29f18be7 100644 --- a/src/client/chat/getPythonEnvTool.ts +++ b/src/client/chat/getPythonEnvTool.ts @@ -31,7 +31,7 @@ export class GetEnvironmentInfoTool implements LanguageModelTool { const envExtension = extensions.getExtension(ENVS_EXTENSION_ID); diff --git a/src/client/chat/installPackagesTool.ts b/src/client/chat/installPackagesTool.ts index 7b34b71ed556..d0531cb015b2 100644 --- a/src/client/chat/installPackagesTool.ts +++ b/src/client/chat/installPackagesTool.ts @@ -26,7 +26,7 @@ export interface IInstallPackageArgs { } export class InstallPackagesTool implements LanguageModelTool { - public static readonly toolName = 'install_python_package'; + public static readonly toolName = 'install_python_packages'; constructor( private readonly api: PythonExtension['environments'], private readonly serviceContainer: IServiceContainer, diff --git a/src/client/chat/listPackagesTool.ts b/src/client/chat/listPackagesTool.ts index 659ce2b5bb0d..a45aeb14cda0 100644 --- a/src/client/chat/listPackagesTool.ts +++ b/src/client/chat/listPackagesTool.ts @@ -1,90 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - CancellationError, - CancellationToken, - l10n, - LanguageModelTextPart, - LanguageModelTool, - LanguageModelToolInvocationOptions, - LanguageModelToolInvocationPrepareOptions, - LanguageModelToolResult, - PreparedToolInvocation, - Uri, -} from 'vscode'; -import { PythonExtension, ResolvedEnvironment } from '../api/types'; -import { IServiceContainer } from '../ioc/types'; +import { CancellationToken, Uri } from 'vscode'; +import { ResolvedEnvironment } from '../api/types'; import { IProcessService, IProcessServiceFactory, IPythonExecutionFactory } from '../common/process/types'; -import { getEnvDisplayName, isCondaEnv, raceCancellationError } from './utils'; -import { resolveFilePath } from './utils'; +import { isCondaEnv, raceCancellationError } from './utils'; import { parsePipList } from './pipListUtils'; import { Conda } from '../pythonEnvironments/common/environmentManagers/conda'; import { traceError } from '../logging'; -import { IDiscoveryAPI } from '../pythonEnvironments/base/locator'; - -export interface IResourceReference { - resourcePath?: string; -} - -export class ListPythonPackagesTool implements LanguageModelTool { - private readonly pythonExecFactory: IPythonExecutionFactory; - private readonly processServiceFactory: IProcessServiceFactory; - public static readonly toolName = 'list_python_packages'; - constructor( - private readonly api: PythonExtension['environments'], - private readonly serviceContainer: IServiceContainer, - private readonly discovery: IDiscoveryAPI, - ) { - this.pythonExecFactory = this.serviceContainer.get(IPythonExecutionFactory); - this.processServiceFactory = this.serviceContainer.get(IProcessServiceFactory); - } - - async invoke( - options: LanguageModelToolInvocationOptions, - token: CancellationToken, - ): Promise { - const resourcePath = resolveFilePath(options.input.resourcePath); - - try { - // environment - const envPath = this.api.getActiveEnvironmentPath(resourcePath); - const environment = await raceCancellationError(this.api.resolveEnvironment(envPath), token); - if (!environment) { - throw new Error('No environment found for the provided resource path: ' + resourcePath?.fsPath); - } - - const message = await getPythonPackagesResponse( - environment, - this.pythonExecFactory, - this.processServiceFactory, - resourcePath, - token, - ); - return new LanguageModelToolResult([new LanguageModelTextPart(message)]); - } catch (error) { - if (error instanceof CancellationError) { - throw error; - } - return new LanguageModelToolResult([ - new LanguageModelTextPart(`An error occurred while fetching environment information: ${error}`), - ]); - } - } - - async prepareInvocation?( - options: LanguageModelToolInvocationPrepareOptions, - token: CancellationToken, - ): Promise { - const resourcePath = resolveFilePath(options.input.resourcePath); - const envName = await raceCancellationError(getEnvDisplayName(this.discovery, resourcePath, this.api), token); - return { - invocationMessage: envName - ? l10n.t('Listing packages in {0}', envName) - : l10n.t('Fetching Python environment information'), - }; - } -} export async function getPythonPackagesResponse( environment: ResolvedEnvironment, diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 362fcf8468ad..8330d5010f7a 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -45,7 +45,7 @@ import { DebugService } from './common/application/debugService'; import { DebugSessionEventDispatcher } from './debugger/extension/hooks/eventHandlerDispatcher'; import { IDebugSessionEventHandlers } from './debugger/extension/hooks/types'; import { WorkspaceService } from './common/application/workspace'; -import { IInterpreterQuickPick } from './interpreter/configuration/types'; +import { IInterpreterQuickPick, IPythonPathUpdaterServiceManager } from './interpreter/configuration/types'; import { registerAllCreateEnvironmentFeatures } from './pythonEnvironments/creation/registrations'; import { registerCreateEnvironmentTriggers } from './pythonEnvironments/creation/createEnvironmentTrigger'; import { initializePersistentStateForTriggers } from './common/persistentState'; @@ -108,7 +108,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): registerAllCreateEnvironmentFeatures( ext.disposables, interpreterQuickPick, - interpreterPathService, + ext.legacyIOC.serviceContainer.get(IPythonPathUpdaterServiceManager), interpreterService, pathUtils, ); diff --git a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 912a9c66b0dd..54440485da02 100644 --- a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -569,7 +569,10 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem * @returns true when an interpreter was set, undefined if the user cancelled the quickpick. */ @captureTelemetry(EventName.SELECT_INTERPRETER) - public async setInterpreter(): Promise { + public async setInterpreter(options?: { + hideCreateVenv?: boolean; + showBackButton?: boolean; + }): Promise { const targetConfig = await this.getConfigTargets(); if (!targetConfig) { return; @@ -578,11 +581,25 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem const wkspace = targetConfig[0].folderUri; const interpreterState: InterpreterStateArgs = { path: undefined, workspace: wkspace }; const multiStep = this.multiStepFactory.create(); - await multiStep.run( - (input, s) => this._pickInterpreter(input, s, undefined, { showCreateEnvironment: true }), - interpreterState, - ); - + try { + await multiStep.run( + (input, s) => + this._pickInterpreter(input, s, undefined, { + showCreateEnvironment: !options?.hideCreateVenv, + showBackButton: options?.showBackButton, + }), + interpreterState, + ); + } catch (ex) { + if (ex === InputFlowAction.back) { + // User clicked back button, so we need to return this action. + return { action: 'Back' }; + } + if (ex === InputFlowAction.cancel) { + // User clicked cancel button, so we need to return this action. + return { action: 'Cancel' }; + } + } if (interpreterState.path !== undefined) { // User may choose to have an empty string stored, so variable `interpreterState.path` may be // an empty string, in which case we should update. @@ -591,7 +608,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem if (useEnvExtension()) { await setInterpreterLegacy(interpreterState.path, wkspace); } - return true; + return { path: interpreterState.path }; } } @@ -692,3 +709,14 @@ function getGroup(item: IInterpreterQuickPickItem, workspacePath?: string) { return EnvGroups[item.interpreter.envType]; } } + +export type SelectEnvironmentResult = { + /** + * Path to the executable python in the environment + */ + readonly path?: string; + /* + * User action that resulted in exit from the create environment flow. + */ + readonly action?: 'Back' | 'Cancel'; +}; diff --git a/src/client/interpreter/configuration/recommededEnvironmentService.ts b/src/client/interpreter/configuration/recommededEnvironmentService.ts index 67517b918ff9..c5356409fcee 100644 --- a/src/client/interpreter/configuration/recommededEnvironmentService.ts +++ b/src/client/interpreter/configuration/recommededEnvironmentService.ts @@ -3,18 +3,33 @@ import { inject, injectable } from 'inversify'; import { IRecommendedEnvironmentService } from './types'; -import { PythonExtension } from '../../api/types'; +import { PythonExtension, ResolvedEnvironment } from '../../api/types'; import { IExtensionContext, Resource } from '../../common/types'; -import { Uri, workspace } from 'vscode'; +import { commands, Uri, workspace } from 'vscode'; import { getWorkspaceStateValue, updateWorkspaceStateValue } from '../../common/persistentState'; import { traceError } from '../../logging'; +import { IExtensionActivationService } from '../../activation/types'; +import { StopWatch } from '../../common/utils/stopWatch'; +import { isParentPath } from '../../common/platform/fs-paths'; const MEMENTO_KEY = 'userSelectedEnvPath'; @injectable() -export class RecommendedEnvironmentService implements IRecommendedEnvironmentService { +export class RecommendedEnvironmentService implements IRecommendedEnvironmentService, IExtensionActivationService { private api?: PythonExtension['environments']; constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + supportedWorkspaceTypes: { untrustedWorkspace: boolean; virtualWorkspace: boolean } = { + untrustedWorkspace: true, + virtualWorkspace: false, + }; + + async activate(_resource: Resource, _startupStopWatch?: StopWatch): Promise { + this.extensionContext.subscriptions.push( + commands.registerCommand('python.getRecommendedEnvironment', async (resource: Resource) => { + return this.getRecommededEnvironment(resource); + }), + ); + } registerEnvApi(api: PythonExtension['environments']) { this.api = api; @@ -32,11 +47,53 @@ export class RecommendedEnvironmentService implements IRecommendedEnvironmentSer } } - getRecommededEnvironment( + async getRecommededEnvironment( + resource: Resource, + ): Promise< + | { + environment: ResolvedEnvironment; + reason: 'globalUserSelected' | 'workspaceUserSelected' | 'defaultRecommended'; + } + | undefined + > { + if (!workspace.isTrusted || !this.api) { + return undefined; + } + const preferred = await this.getRecommededInternal(resource); + if (!preferred) { + return undefined; + } + const activeEnv = await this.api.resolveEnvironment(this.api.getActiveEnvironmentPath(resource)); + const recommendedEnv = await this.api.resolveEnvironment(preferred.environmentPath); + if (activeEnv && recommendedEnv && activeEnv.id !== recommendedEnv.id) { + traceError( + `Active environment ${activeEnv.id} is different from recommended environment ${ + recommendedEnv.id + } for resource ${resource?.toString()}`, + ); + return undefined; + } + if (recommendedEnv) { + return { environment: recommendedEnv, reason: preferred.reason }; + } + const globalEnv = await this.api.resolveEnvironment(this.api.getActiveEnvironmentPath()); + if (activeEnv && globalEnv?.path !== activeEnv?.path) { + // User has definitely got a workspace specific environment selected. + // Given the fact that global !== workspace env, we can safely assume that + // at some time, the user has selected a workspace specific environment. + // This applies to cases where the user has selected a workspace specific environment before this version of the extension + // and we did not store it in the workspace state. + // So we can safely return the global environment as the recommended environment. + return { environment: activeEnv, reason: 'workspaceUserSelected' }; + } + return undefined; + } + async getRecommededInternal( resource: Resource, - ): + ): Promise< | { environmentPath: string; reason: 'globalUserSelected' | 'workspaceUserSelected' | 'defaultRecommended' } - | undefined { + | undefined + > { let workspaceState: string | undefined = undefined; try { workspaceState = getWorkspaceStateValue(MEMENTO_KEY); @@ -61,6 +118,16 @@ export class RecommendedEnvironmentService implements IRecommendedEnvironmentSer } } + if (workspace.workspaceFolders?.length && this.api) { + // Check if we have a .venv or .conda environment in the workspace + // This is required for cases where user has selected a workspace specific environment + // but before this version of the extension, we did not store it in the workspace state. + const workspaceEnv = await getWorkspaceSpecificVirtualEnvironment(this.api, resource); + if (workspaceEnv) { + return { environmentPath: workspaceEnv.path, reason: 'workspaceUserSelected' }; + } + } + const globalSelectedEnvPath = this.extensionContext.globalState.get(MEMENTO_KEY); if (globalSelectedEnvPath) { return { environmentPath: globalSelectedEnvPath, reason: 'globalUserSelected' }; @@ -74,6 +141,37 @@ export class RecommendedEnvironmentService implements IRecommendedEnvironmentSer } } +async function getWorkspaceSpecificVirtualEnvironment(api: PythonExtension['environments'], resource: Resource) { + const workspaceUri = + (resource ? workspace.getWorkspaceFolder(resource)?.uri : undefined) || + (workspace.workspaceFolders?.length ? workspace.workspaceFolders[0].uri : undefined); + if (!workspaceUri) { + return undefined; + } + let workspaceEnv = api.known.find((env) => { + if (!env.environment?.folderUri) { + return false; + } + if (env.environment.type !== 'VirtualEnvironment' && env.environment.type !== 'Conda') { + return false; + } + return isParentPath(env.environment.folderUri.fsPath, workspaceUri.fsPath); + }); + let resolvedEnv = workspaceEnv ? api.resolveEnvironment(workspaceEnv) : undefined; + if (resolvedEnv) { + return resolvedEnv; + } + workspaceEnv = api.known.find((env) => { + // Look for any other type of env thats inside this workspace + // Or look for an env thats associated with this workspace (pipenv or the like). + return ( + (env.environment?.folderUri && isParentPath(env.environment.folderUri.fsPath, workspaceUri.fsPath)) || + (env.environment?.workspaceFolder && env.environment.workspaceFolder.uri.fsPath === workspaceUri.fsPath) + ); + }); + return workspaceEnv ? api.resolveEnvironment(workspaceEnv) : undefined; +} + function getDataToStore(environmentPath: string | undefined, uri: Uri | undefined): string | undefined { if (!workspace.workspaceFolders?.length) { return environmentPath; diff --git a/src/client/interpreter/configuration/types.ts b/src/client/interpreter/configuration/types.ts index 8e8aecdc7f16..05ff8e32c18e 100644 --- a/src/client/interpreter/configuration/types.ts +++ b/src/client/interpreter/configuration/types.ts @@ -1,7 +1,7 @@ import { ConfigurationTarget, Disposable, QuickPickItem, Uri } from 'vscode'; import { Resource } from '../../common/types'; import { PythonEnvironment } from '../../pythonEnvironments/info'; -import { PythonExtension } from '../../api/types'; +import { PythonExtension, ResolvedEnvironment } from '../../api/types'; export interface IPythonPathUpdaterService { updatePythonPath(pythonPath: string | undefined): Promise; @@ -104,7 +104,11 @@ export interface IRecommendedEnvironmentService { trackUserSelectedEnvironment(environmentPath: string | undefined, uri: Uri | undefined): void; getRecommededEnvironment( resource: Resource, - ): - | { environmentPath: string; reason: 'globalUserSelected' | 'workspaceUserSelected' | 'defaultRecommended' } - | undefined; + ): Promise< + | { + environment: ResolvedEnvironment; + reason: 'globalUserSelected' | 'workspaceUserSelected' | 'defaultRecommended'; + } + | undefined + >; } diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index 1e82b9fec0df..f54f8e5368fe 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -65,6 +65,7 @@ export function registerInterpreterTypes(serviceManager: IServiceManager): void IRecommendedEnvironmentService, RecommendedEnvironmentService, ); + serviceManager.addBinding(IRecommendedEnvironmentService, IExtensionActivationService); serviceManager.addSingleton(IInterpreterQuickPick, SetInterpreterCommand); serviceManager.addSingleton(IExtensionActivationService, VirtualEnvironmentPrompt); diff --git a/src/client/jupyter/jupyterIntegration.ts b/src/client/jupyter/jupyterIntegration.ts index b81542806daf..5584682f3b86 100644 --- a/src/client/jupyter/jupyterIntegration.ts +++ b/src/client/jupyter/jupyterIntegration.ts @@ -72,7 +72,7 @@ type PythonApiForJupyterExtension = { * Returns the preferred environment for the given URI. */ getRecommededEnvironment( - uri: Uri, + uri: Uri | undefined, ): Promise< | { environment: EnvironmentPath; @@ -147,14 +147,7 @@ export class JupyterExtensionIntegration { if (!this.environmentApi) { return undefined; } - const preferred = this.preferredEnvironmentService.getRecommededEnvironment(uri); - if (!preferred) { - return undefined; - } - const environment = workspace.isTrusted - ? await this.environmentApi.resolveEnvironment(preferred.environmentPath) - : undefined; - return environment ? { environment, reason: preferred.reason } : undefined; + return this.preferredEnvironmentService.getRecommededEnvironment(uri); }, }); return undefined; diff --git a/src/client/pythonEnvironments/creation/createEnvApi.ts b/src/client/pythonEnvironments/creation/createEnvApi.ts index b5df9232dd4b..eb094c7d128a 100644 --- a/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -3,9 +3,9 @@ import { ConfigurationTarget, Disposable, QuickInputButtons } from 'vscode'; import { Commands } from '../../common/constants'; -import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../common/types'; +import { IDisposableRegistry, IPathUtils } from '../../common/types'; import { executeCommand, registerCommand } from '../../common/vscodeApis/commandApis'; -import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; +import { IInterpreterQuickPick, IPythonPathUpdaterServiceManager } from '../../interpreter/configuration/types'; import { getCreationEvents, handleCreateEnvironmentCommand } from './createEnvironment'; import { condaCreationProvider } from './provider/condaCreationProvider'; import { VenvCreationProvider } from './provider/venvCreationProvider'; @@ -61,7 +61,7 @@ export const { onCreateEnvironmentStarted, onCreateEnvironmentExited, isCreating export function registerCreateEnvironmentFeatures( disposables: IDisposableRegistry, interpreterQuickPick: IInterpreterQuickPick, - interpreterPathService: IInterpreterPathService, + pythonPathUpdater: IPythonPathUpdaterServiceManager, pathUtils: IPathUtils, ): void { disposables.push( @@ -103,10 +103,11 @@ export function registerCreateEnvironmentFeatures( registerCreateEnvironmentProvider(condaCreationProvider()), onCreateEnvironmentExited(async (e: EnvironmentDidCreateEvent) => { if (e.path && e.options?.selectEnvironment) { - await interpreterPathService.update( - e.workspaceFolder?.uri, - ConfigurationTarget.WorkspaceFolder, + await pythonPathUpdater.updatePythonPath( e.path, + ConfigurationTarget.WorkspaceFolder, + 'ui', + e.workspaceFolder?.uri, ); showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.path)}`); } diff --git a/src/client/pythonEnvironments/creation/registrations.ts b/src/client/pythonEnvironments/creation/registrations.ts index 8611520fc0f2..25141cbec5ac 100644 --- a/src/client/pythonEnvironments/creation/registrations.ts +++ b/src/client/pythonEnvironments/creation/registrations.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../common/types'; -import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; +import { IDisposableRegistry, IPathUtils } from '../../common/types'; +import { IInterpreterQuickPick, IPythonPathUpdaterServiceManager } from '../../interpreter/configuration/types'; import { IInterpreterService } from '../../interpreter/contracts'; import { registerCreateEnvironmentFeatures } from './createEnvApi'; import { registerCreateEnvironmentButtonFeatures } from './createEnvButtonContext'; @@ -13,11 +13,11 @@ import { registerPyProjectTomlFeatures } from './pyProjectTomlContext'; export function registerAllCreateEnvironmentFeatures( disposables: IDisposableRegistry, interpreterQuickPick: IInterpreterQuickPick, - interpreterPathService: IInterpreterPathService, + pythonPathUpdater: IPythonPathUpdaterServiceManager, interpreterService: IInterpreterService, pathUtils: IPathUtils, ): void { - registerCreateEnvironmentFeatures(disposables, interpreterQuickPick, interpreterPathService, pathUtils); + registerCreateEnvironmentFeatures(disposables, interpreterQuickPick, pythonPathUpdater, pathUtils); registerCreateEnvironmentButtonFeatures(disposables); registerPyProjectTomlFeatures(disposables); registerInstalledPackagesDiagnosticsProvider(disposables, interpreterService); diff --git a/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts index f8c75f76e2b8..dd09203d65cc 100644 --- a/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts +++ b/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts @@ -7,9 +7,12 @@ 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 { IDisposableRegistry, IPathUtils } from '../../../client/common/types'; import * as commandApis from '../../../client/common/vscodeApis/commandApis'; -import { IInterpreterQuickPick } from '../../../client/interpreter/configuration/types'; +import { + IInterpreterQuickPick, + IPythonPathUpdaterServiceManager, +} from '../../../client/interpreter/configuration/types'; import { registerCreateEnvironmentFeatures } from '../../../client/pythonEnvironments/creation/createEnvApi'; import * as windowApis from '../../../client/common/vscodeApis/windowApis'; import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; @@ -23,7 +26,7 @@ suite('Create Environment APIs', () => { let showInformationMessageStub: sinon.SinonStub; const disposables: IDisposableRegistry = []; let interpreterQuickPick: typemoq.IMock; - let interpreterPathService: typemoq.IMock; + let interpreterPathService: typemoq.IMock; let pathUtils: typemoq.IMock; setup(() => { @@ -32,7 +35,7 @@ suite('Create Environment APIs', () => { registerCommandStub = sinon.stub(commandApis, 'registerCommand'); interpreterQuickPick = typemoq.Mock.ofType(); - interpreterPathService = typemoq.Mock.ofType(); + interpreterPathService = typemoq.Mock.ofType(); pathUtils = typemoq.Mock.ofType(); registerCommandStub.callsFake((_command: string, _callback: (...args: any[]) => any) => ({ @@ -82,10 +85,11 @@ suite('Create Environment APIs', () => { interpreterPathService .setup((p) => - p.update( - typemoq.It.isAny(), - ConfigurationTarget.WorkspaceFolder, + p.updatePythonPath( typemoq.It.isValue('/path/to/env'), + ConfigurationTarget.WorkspaceFolder, + 'ui', + typemoq.It.isAny(), ), ) .returns(() => Promise.resolve())