diff --git a/package.json b/package.json index 5d4c3dc4d0bb..3d0cd34bbf17 100644 --- a/package.json +++ b/package.json @@ -1475,6 +1475,7 @@ "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": [ + "extension_installed_by_tool", "enable_other_tool_configure_python_environment" ], "icon": "$(snake)", @@ -1498,6 +1499,7 @@ "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": [ + "extension_installed_by_tool", "enable_other_tool_configure_python_environment" ], "icon": "$(terminal)", @@ -1521,6 +1523,7 @@ "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": [ + "extension_installed_by_tool", "enable_other_tool_configure_python_environment" ], "icon": "$(package)", @@ -1552,7 +1555,9 @@ "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": [], + "tags": [ + "extension_installed_by_tool" + ], "icon": "$(gear)", "canBeReferencedInPrompt": true, "inputSchema": { @@ -1566,6 +1571,46 @@ "required": [] }, "when": "!pythonEnvExtensionInstalled" + }, + { + "name": "create_virtual_environment", + "displayName": "Create a Virtual Environment", + "modelDescription": "This tool will create a Virual Environment", + "tags": [ + "extension_installed_by_tool" + ], + "canBeReferencedInPrompt": false, + "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": "false" + }, + { + "name": "selectEnvironment", + "displayName": "Select a Python Environment", + "modelDescription": "This tool will prompt the user to select an existing Python Environment", + "tags": [ + "extension_installed_by_tool" + ], + "canBeReferencedInPrompt": false, + "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": "false" } ] }, diff --git a/src/client/chat/configurePythonEnvTool.ts b/src/client/chat/configurePythonEnvTool.ts index f0684a9cd46a..a8a18a1d3852 100644 --- a/src/client/chat/configurePythonEnvTool.ts +++ b/src/client/chat/configurePythonEnvTool.ts @@ -3,8 +3,6 @@ import { CancellationToken, - l10n, - LanguageModelTextPart, LanguageModelTool, LanguageModelToolInvocationOptions, LanguageModelToolInvocationPrepareOptions, @@ -12,32 +10,24 @@ import { PreparedToolInvocation, Uri, workspace, - commands, - QuickPickItem, + lm, } from 'vscode'; -import { PythonExtension, ResolvedEnvironment } from '../api/types'; +import { PythonExtension } from '../api/types'; import { IServiceContainer } from '../ioc/types'; import { ICodeExecutionService } from '../terminals/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; -import { getEnvironmentDetails, getToolResponseIfNotebook, raceCancellationError } from './utils'; +import { + getEnvDetailsForResponse, + getToolResponseIfNotebook, + IResourceReference, + isCancellationError, + 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; +import { IRecommendedEnvironmentService } from '../interpreter/configuration/types'; +import { CreateVirtualEnvTool } from './createVirtualEnvTool'; +import { ISelectPythonEnvToolArguments, SelectPythonEnvTool } from './selectEnvTool'; export class ConfigurePythonEnvTool implements LanguageModelTool { private readonly terminalExecutionService: TerminalCodeExecutionProvider; @@ -47,6 +37,7 @@ export class ConfigurePythonEnvTool implements LanguageModelTool( ICodeExecutionService, @@ -57,12 +48,7 @@ export class ConfigurePythonEnvTool implements LanguageModelTool, token: CancellationToken, @@ -73,22 +59,14 @@ export class ConfigurePythonEnvTool implements LanguageModelTool, + _options: LanguageModelToolInvocationPrepareOptions, _token: CancellationToken, ): Promise { - if (_environmentConfigured) { - return {}; - } - const resource = resolveFilePath(options.input.resourcePath); - if (getToolResponseIfNotebook(resource)) { - return {}; - } + return { + invocationMessage: 'Configuring a Python Environment', + }; + } + + async hasAlreadyGotAWorkspaceSpecificEnvironment(resource: Uri | undefined) { const recommededEnv = await this.recommendedEnvService.getRecommededEnvironment(resource); // Already selected workspace env, hence nothing to do. if (recommededEnv?.reason === 'workspaceUserSelected' && workspace.workspaceFolders?.length) { - return {}; + return recommededEnv.environment; } // 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; + return recommededEnv.environment; } } } diff --git a/src/client/chat/createVirtualEnvTool.ts b/src/client/chat/createVirtualEnvTool.ts new file mode 100644 index 000000000000..b62fa33ea02f --- /dev/null +++ b/src/client/chat/createVirtualEnvTool.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + CancellationError, + CancellationToken, + l10n, + LanguageModelTool, + LanguageModelToolInvocationOptions, + LanguageModelToolInvocationPrepareOptions, + LanguageModelToolResult, + PreparedToolInvocation, + Uri, + workspace, +} 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 { + doesWorkspaceHaveVenvOrCondaEnv, + getDisplayVersion, + getEnvDetailsForResponse, + IResourceReference, + isCancellationError, + raceCancellationError, +} from './utils'; +import { resolveFilePath } from './utils'; +import { ITerminalHelper } from '../common/terminal/types'; +import { raceTimeout, sleep } from '../common/utils/async'; +import { IInterpreterPathService } from '../common/types'; +import { DisposableStore } from '../common/utils/resourceLifecycle'; +import { IRecommendedEnvironmentService } from '../interpreter/configuration/types'; +import { EnvironmentType } from '../pythonEnvironments/info'; +import { IDiscoveryAPI } from '../pythonEnvironments/base/locator'; +import { convertEnvInfoToPythonEnvironment } from '../pythonEnvironments/legacyIOC'; +import { sortInterpreters } from '../interpreter/helpers'; +import { isStableVersion } from '../pythonEnvironments/info/pythonVersion'; +import { createVirtualEnvironment } from '../pythonEnvironments/creation/createEnvApi'; +import { traceError, traceVerbose, traceWarn } from '../logging'; +import { StopWatch } from '../common/utils/stopWatch'; + +export class CreateVirtualEnvTool implements LanguageModelTool { + private readonly terminalExecutionService: TerminalCodeExecutionProvider; + private readonly terminalHelper: ITerminalHelper; + private readonly recommendedEnvService: IRecommendedEnvironmentService; + + public static readonly toolName = 'create_virtual_environment'; + constructor( + private readonly discoveryApi: IDiscoveryAPI, + 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, + ); + } + + async invoke( + options: LanguageModelToolInvocationOptions, + token: CancellationToken, + ): Promise { + const resource = resolveFilePath(options.input.resourcePath); + let info = await this.getPreferredEnvForCreation(resource); + if (!info) { + traceWarn(`Called ${CreateVirtualEnvTool.toolName} tool not invoked, no preferred environment found.`); + throw new CancellationError(); + } + const { workspaceFolder, preferredGlobalPythonEnv } = info; + const interpreterPathService = this.serviceContainer.get(IInterpreterPathService); + const disposables = new DisposableStore(); + try { + const interpreterChanged = new Promise((resolve) => { + disposables.add(interpreterPathService.onDidChange(() => resolve())); + }); + + const created = await raceCancellationError( + createVirtualEnvironment({ + interpreter: preferredGlobalPythonEnv.id, + workspaceFolder, + }), + token, + ); + if (!created?.path) { + traceWarn(`${CreateVirtualEnvTool.toolName} tool not invoked, virtual env not created.`); + throw new CancellationError(); + } + + // Wait a few secs to ensure the env is selected as the active environment.. + // If this doesn't work, then something went wrong. + await raceTimeout(5_000, interpreterChanged); + + const stopWatch = new StopWatch(); + let env: ResolvedEnvironment | undefined; + while (stopWatch.elapsedTime < 5_000 || !env) { + env = await this.api.resolveEnvironment(created.path); + if (env) { + break; + } else { + traceVerbose( + `${CreateVirtualEnvTool.toolName} tool invoked, env created but not yet resolved, waiting...`, + ); + await sleep(200); + } + } + if (!env) { + traceError(`${CreateVirtualEnvTool.toolName} tool invoked, env created but unable to resolve details.`); + throw new CancellationError(); + } + return await getEnvDetailsForResponse( + env, + this.api, + this.terminalExecutionService, + this.terminalHelper, + resource, + token, + ); + } catch (ex) { + if (!isCancellationError(ex)) { + traceError( + `${ + CreateVirtualEnvTool.toolName + } tool failed to create virtual environment for resource ${resource?.toString()}`, + ex, + ); + } + throw ex; + } finally { + disposables.dispose(); + } + } + + public async shouldCreateNewVirtualEnv(resource: Uri | undefined, token: CancellationToken): Promise { + if (doesWorkspaceHaveVenvOrCondaEnv(resource, this.api)) { + // If we already have a .venv or .conda in this workspace, then do not prompt to create a virtual environment. + return false; + } + + const info = await raceCancellationError(this.getPreferredEnvForCreation(resource), token); + return info ? true : false; + } + + async prepareInvocation?( + options: LanguageModelToolInvocationPrepareOptions, + token: CancellationToken, + ): Promise { + const resource = resolveFilePath(options.input.resourcePath); + const info = await raceCancellationError(this.getPreferredEnvForCreation(resource), token); + if (!info) { + return {}; + } + const { preferredGlobalPythonEnv } = info; + const version = getDisplayVersion(preferredGlobalPythonEnv.version); + return { + confirmationMessages: { + title: l10n.t('Create a Virtual Environment{0}?', version ? ` (${version})` : ''), + message: l10n.t(`Virtual Environments provide the benefit of package isolation and more.`), + }, + }; + } + async hasAlreadyGotAWorkspaceSpecificEnvironment(resource: Uri | undefined) { + const recommededEnv = await this.recommendedEnvService.getRecommededEnvironment(resource); + // Already selected workspace env, hence nothing to do. + if (recommededEnv?.reason === 'workspaceUserSelected' && workspace.workspaceFolders?.length) { + return recommededEnv.environment; + } + // No workspace folders, and the user selected a global environment. + if (recommededEnv?.reason === 'globalUserSelected' && !workspace.workspaceFolders?.length) { + return recommededEnv.environment; + } + } + + private async getPreferredEnvForCreation(resource: Uri | undefined) { + if (await this.hasAlreadyGotAWorkspaceSpecificEnvironment(resource)) { + return undefined; + } + + // If we have a resource or have only one workspace folder && there is no .venv and no workspace specific environment. + // Then lets recommend creating a virtual environment. + const workspaceFolder = + resource && workspace.workspaceFolders?.length + ? workspace.getWorkspaceFolder(resource) + : workspace.workspaceFolders?.length === 1 + ? workspace.workspaceFolders[0] + : undefined; + if (!workspaceFolder) { + // No workspace folder, hence no need to create a virtual environment. + return undefined; + } + + // Find the latest stable version of Python from the list of know envs. + let globalPythonEnvs = this.discoveryApi + .getEnvs() + .map((env) => convertEnvInfoToPythonEnvironment(env)) + .filter((env) => + [ + EnvironmentType.System, + EnvironmentType.MicrosoftStore, + EnvironmentType.Global, + EnvironmentType.Pyenv, + ].includes(env.envType), + ) + .filter((env) => env.version && isStableVersion(env.version)); + + globalPythonEnvs = sortInterpreters(globalPythonEnvs); + const preferredGlobalPythonEnv = globalPythonEnvs.length + ? this.api.known.find((e) => e.id === globalPythonEnvs[globalPythonEnvs.length - 1].id) + : undefined; + + return workspaceFolder && preferredGlobalPythonEnv + ? { + workspaceFolder, + preferredGlobalPythonEnv, + } + : undefined; + } +} diff --git a/src/client/chat/getExecutableTool.ts b/src/client/chat/getExecutableTool.ts index 09a70cfeb273..125e3a1f98da 100644 --- a/src/client/chat/getExecutableTool.ts +++ b/src/client/chat/getExecutableTool.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { - CancellationError, CancellationToken, l10n, LanguageModelTextPart, @@ -16,16 +15,17 @@ import { PythonExtension } from '../api/types'; import { IServiceContainer } from '../ioc/types'; import { ICodeExecutionService } from '../terminals/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; -import { getEnvDisplayName, getEnvironmentDetails, getToolResponseIfNotebook, raceCancellationError } from './utils'; +import { + getEnvDisplayName, + getEnvironmentDetails, + getToolResponseIfNotebook, + IResourceReference, + raceCancellationError, +} from './utils'; import { resolveFilePath } from './utils'; -import { traceError } from '../logging'; import { ITerminalHelper } from '../common/terminal/types'; import { IDiscoveryAPI } from '../pythonEnvironments/base/locator'; -export interface IResourceReference { - resourcePath?: string; -} - export class GetExecutableTool implements LanguageModelTool { private readonly terminalExecutionService: TerminalCodeExecutionProvider; private readonly terminalHelper: ITerminalHelper; @@ -51,24 +51,15 @@ export class GetExecutableTool implements LanguageModelTool return notebookResponse; } - try { - const message = await getEnvironmentDetails( - resourcePath, - this.api, - this.terminalExecutionService, - this.terminalHelper, - undefined, - token, - ); - return new LanguageModelToolResult([new LanguageModelTextPart(message)]); - } catch (error) { - if (error instanceof CancellationError) { - throw error; - } - traceError('Error while getting environment information', error); - const errorMessage: string = `An error occurred while fetching environment information: ${error}`; - return new LanguageModelToolResult([new LanguageModelTextPart(errorMessage)]); - } + const message = await getEnvironmentDetails( + resourcePath, + this.api, + this.terminalExecutionService, + this.terminalHelper, + undefined, + token, + ); + return new LanguageModelToolResult([new LanguageModelTextPart(message)]); } async prepareInvocation?( diff --git a/src/client/chat/getPythonEnvTool.ts b/src/client/chat/getPythonEnvTool.ts index 91f7fccd3de5..5ec8e77c6c1e 100644 --- a/src/client/chat/getPythonEnvTool.ts +++ b/src/client/chat/getPythonEnvTool.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { - CancellationError, CancellationToken, l10n, LanguageModelTextPart, @@ -17,15 +16,11 @@ import { IServiceContainer } from '../ioc/types'; import { ICodeExecutionService } from '../terminals/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; import { IProcessServiceFactory, IPythonExecutionFactory } from '../common/process/types'; -import { getEnvironmentDetails, getToolResponseIfNotebook, raceCancellationError } from './utils'; +import { getEnvironmentDetails, getToolResponseIfNotebook, IResourceReference, raceCancellationError } from './utils'; import { resolveFilePath } from './utils'; import { getPythonPackagesResponse } from './listPackagesTool'; import { ITerminalHelper } from '../common/terminal/types'; -export interface IResourceReference { - resourcePath?: string; -} - export class GetEnvironmentInfoTool implements LanguageModelTool { private readonly terminalExecutionService: TerminalCodeExecutionProvider; private readonly pythonExecFactory: IPythonExecutionFactory; @@ -44,12 +39,7 @@ export class GetEnvironmentInfoTool implements LanguageModelTool(IProcessServiceFactory); this.terminalHelper = this.serviceContainer.get(ITerminalHelper); } - /** - * 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, @@ -60,38 +50,30 @@ export class GetEnvironmentInfoTool implements LanguageModelTool { diff --git a/src/client/chat/installPackagesTool.ts b/src/client/chat/installPackagesTool.ts index 04bb5d04a493..36544128582a 100644 --- a/src/client/chat/installPackagesTool.ts +++ b/src/client/chat/installPackagesTool.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { - CancellationError, CancellationToken, l10n, LanguageModelTextPart, @@ -14,14 +13,20 @@ import { } from 'vscode'; import { PythonExtension } from '../api/types'; import { IServiceContainer } from '../ioc/types'; -import { getEnvDisplayName, getToolResponseIfNotebook, raceCancellationError } from './utils'; +import { + getEnvDisplayName, + getToolResponseIfNotebook, + IResourceReference, + isCancellationError, + isCondaEnv, + raceCancellationError, +} from './utils'; import { resolveFilePath } from './utils'; import { IModuleInstaller } from '../common/installer/types'; import { ModuleInstallerType } from '../pythonEnvironments/info'; import { IDiscoveryAPI } from '../pythonEnvironments/base/locator'; -export interface IInstallPackageArgs { - resourcePath?: string; +export interface IInstallPackageArgs extends IResourceReference { packageList: string[]; } @@ -32,12 +37,7 @@ export class InstallPackagesTool implements LanguageModelTool, token: CancellationToken, @@ -57,7 +57,7 @@ export class InstallPackagesTool implements LanguageModelTool(IModuleInstaller); const installerType = isConda ? ModuleInstallerType.Conda : ModuleInstallerType.Pip; const installer = installers.find((i) => i.type === installerType); @@ -74,7 +74,7 @@ export class InstallPackagesTool implements LanguageModelTool { // Add option --format to subcommand list of pip cache, with abspath choice to output the full path of a wheel file. (#8355) - // Added in 202. Thats almost 5 years ago. When Python 3.8 was released. + // Added in 2020. Thats almost 5 years ago. When Python 3.8 was released. const exec = await execFactory.createActivatedEnvironment({ allowEnvironmentFetchExceptions: true, resource }); const output = await exec.execModule('pip', ['list'], { throwOnStdErr: false, encoding: 'utf8' }); return parsePipList(output.stdout).map((pkg) => [pkg.name, pkg.version]); diff --git a/src/client/chat/selectEnvTool.ts b/src/client/chat/selectEnvTool.ts new file mode 100644 index 000000000000..ba0b7d16c77b --- /dev/null +++ b/src/client/chat/selectEnvTool.ts @@ -0,0 +1,216 @@ +// 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, + QuickPickItemKind, +} from 'vscode'; +import { PythonExtension } from '../api/types'; +import { IServiceContainer } from '../ioc/types'; +import { ICodeExecutionService } from '../terminals/types'; +import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; +import { + doesWorkspaceHaveVenvOrCondaEnv, + getEnvDetailsForResponse, + getToolResponseIfNotebook, + IResourceReference, +} from './utils'; +import { resolveFilePath } from './utils'; +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 { SelectEnvironmentResult } from '../interpreter/configuration/interpreterSelector/commands/setInterpreter'; +import { Common, InterpreterQuickPickList } from '../common/utils/localize'; +import { showQuickPick } from '../common/vscodeApis/windowApis'; +import { DisposableStore } from '../common/utils/resourceLifecycle'; +import { traceError, traceVerbose, traceWarn } from '../logging'; + +export interface ISelectPythonEnvToolArguments extends IResourceReference { + reason?: 'cancelled'; +} + +export class SelectPythonEnvTool implements LanguageModelTool { + private readonly terminalExecutionService: TerminalCodeExecutionProvider; + private readonly terminalHelper: ITerminalHelper; + public static readonly toolName = 'selectEnvironment'; + constructor( + private readonly api: PythonExtension['environments'], + private readonly serviceContainer: IServiceContainer, + ) { + this.terminalExecutionService = this.serviceContainer.get( + ICodeExecutionService, + 'standard', + ); + this.terminalHelper = this.serviceContainer.get(ITerminalHelper); + } + + async invoke( + options: LanguageModelToolInvocationOptions, + token: CancellationToken, + ): Promise { + const resource = resolveFilePath(options.input.resourcePath); + let selected: boolean | undefined = false; + const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api); + if (options.input.reason === 'cancelled' || hasVenvOrCondaEnvInWorkspaceFolder) { + const result = (await Promise.resolve( + commands.executeCommand(Commands.Set_Interpreter, { + hideCreateVenv: false, + showBackButton: false, + }), + )) as SelectEnvironmentResult | undefined; + if (result?.path) { + traceVerbose(`User selected a Python environment ${result.path} in Select Python Tool.`); + selected = true; + } else { + traceWarn(`User did not select a Python environment in Select Python Tool.`); + } + } else { + selected = await showCreateAndSelectEnvironmentQuickPick(resource, this.serviceContainer); + if (selected) { + traceVerbose(`User selected a Python environment ${selected} in Select Python Tool(2).`); + } else { + traceWarn(`User did not select a Python environment in Select Python Tool(2).`); + } + } + const env = selected + ? await this.api.resolveEnvironment(this.api.getActiveEnvironmentPath(resource)) + : undefined; + if (selected && !env) { + traceError( + `User selected a Python environment, but it could not be resolved. This is unexpected. Environment: ${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 { + const resource = resolveFilePath(options.input.resourcePath); + if (getToolResponseIfNotebook(resource)) { + return {}; + } + const hasVenvOrCondaEnvInWorkspaceFolder = doesWorkspaceHaveVenvOrCondaEnv(resource, this.api); + + if ( + hasVenvOrCondaEnvInWorkspaceFolder || + !workspace.workspaceFolders?.length || + options.input.reason === 'cancelled' + ) { + return { + confirmationMessages: { + title: l10n.t('Select a Python Environment?'), + message: '', + }, + }; + } + + 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 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/utils.ts b/src/client/chat/utils.ts index e6d43e0dcb61..cd13dc867615 100644 --- a/src/client/chat/utils.ts +++ b/src/client/chat/utils.ts @@ -11,11 +11,16 @@ import { workspace, } from 'vscode'; import { IDiscoveryAPI } from '../pythonEnvironments/base/locator'; -import { PythonExtension, ResolvedEnvironment } from '../api/types'; +import { Environment, PythonExtension, ResolvedEnvironment, VersionInfo } from '../api/types'; import { ITerminalHelper, TerminalShellType } from '../common/terminal/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; import { Conda } from '../pythonEnvironments/common/environmentManagers/conda'; import { JUPYTER_EXTENSION_ID, NotebookCellScheme } from '../common/constants'; +import { dirname, join } from 'path'; + +export interface IResourceReference { + resourcePath?: string; +} export function resolveFilePath(filepath?: string): Uri | undefined { if (!filepath) { @@ -156,3 +161,73 @@ export function getToolResponseIfNotebook(resource: Uri | undefined) { ]); } } + +export function isCancellationError(error: unknown): boolean { + return ( + !!error && (error instanceof CancellationError || (error as Error).message === new CancellationError().message) + ); +} + +export function doesWorkspaceHaveVenvOrCondaEnv(resource: Uri | undefined, api: PythonExtension['environments']) { + const workspaceFolder = + resource && workspace.workspaceFolders?.length + ? workspace.getWorkspaceFolder(resource) + : workspace.workspaceFolders?.length === 1 + ? workspace.workspaceFolders[0] + : undefined; + if (!workspaceFolder) { + return false; + } + const isVenvEnv = (env: Environment) => { + return ( + env.environment?.folderUri && + env.executable.sysPrefix && + dirname(env.executable.sysPrefix) === workspaceFolder.uri.fsPath && + env.environment.name === '.venv' && + env.environment.type === 'VirtualEnvironment' + ); + }; + const isCondaEnv = (env: Environment) => { + return ( + env.environment?.folderUri && + env.executable.sysPrefix && + dirname(env.executable.sysPrefix) === workspaceFolder.uri.fsPath && + env.environment.folderUri.fsPath === join(workspaceFolder.uri.fsPath, '.conda') && + env.environment.type === 'Conda' + ); + }; + // If we alraedy have a .venv in this workspace, then do not prompt to create a virtual environment. + return api.known.find((e) => isVenvEnv(e) || isCondaEnv(e)); +} + +export 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), + ]); +} +export function getDisplayVersion(version?: VersionInfo): string | undefined { + if (!version || version.major === undefined || version.minor === undefined || version.micro === undefined) { + return undefined; + } + return `${version.major}.${version.minor}.${version.micro}`; +} diff --git a/src/client/interpreter/helpers.ts b/src/client/interpreter/helpers.ts index ded855cbd55b..413fa225f3ef 100644 --- a/src/client/interpreter/helpers.ts +++ b/src/client/interpreter/helpers.ts @@ -19,7 +19,7 @@ export function isInterpreterLocatedInWorkspace(interpreter: PythonEnvironment, /** * Build a version-sorted list from the given one, with lowest first. */ -function sortInterpreters(interpreters: PythonEnvironment[]): PythonEnvironment[] { +export function sortInterpreters(interpreters: PythonEnvironment[]): PythonEnvironment[] { if (interpreters.length === 0) { return []; } diff --git a/src/client/pythonEnvironments/creation/createEnvApi.ts b/src/client/pythonEnvironments/creation/createEnvApi.ts index eb094c7d128a..d585256200d8 100644 --- a/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -8,7 +8,7 @@ import { executeCommand, registerCommand } from '../../common/vscodeApis/command import { IInterpreterQuickPick, IPythonPathUpdaterServiceManager } from '../../interpreter/configuration/types'; import { getCreationEvents, handleCreateEnvironmentCommand } from './createEnvironment'; import { condaCreationProvider } from './provider/condaCreationProvider'; -import { VenvCreationProvider } from './provider/venvCreationProvider'; +import { VenvCreationProvider, VenvCreationProviderId } from './provider/venvCreationProvider'; import { showInformationMessage } from '../../common/vscodeApis/windowApis'; import { CreateEnv } from '../../common/utils/localize'; import { @@ -133,3 +133,11 @@ export function buildEnvironmentCreationApi(): ProposedCreateEnvironmentAPI { registerCreateEnvironmentProvider(provider), }; } + +export async function createVirtualEnvironment(options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal) { + const provider = _createEnvironmentProviders.getAll().find((p) => p.id === VenvCreationProviderId); + if (!provider) { + return; + } + return handleCreateEnvironmentCommand([provider], { ...options, providerId: provider.id }); +} diff --git a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts index 6b6ce182e887..9f5d746d55ae 100644 --- a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -149,21 +149,26 @@ async function createVenv( return deferred.promise; } +export const VenvCreationProviderId = `${PVSC_EXTENSION_ID}:venv`; export class VenvCreationProvider implements CreateEnvironmentProvider { constructor(private readonly interpreterQuickPick: IInterpreterQuickPick) {} public async createEnvironment( options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, ): Promise { - let workspace: WorkspaceFolder | undefined; + let workspace = options?.workspaceFolder; + const bypassQuickPicks = options?.workspaceFolder && options.interpreter && options.providerId ? true : false; const workspaceStep = new MultiStepNode( undefined, async (context?: MultiStepAction) => { try { - workspace = (await pickWorkspaceFolder( - { preSelectedWorkspace: options?.workspaceFolder }, - context, - )) as WorkspaceFolder | undefined; + workspace = + workspace && bypassQuickPicks + ? workspace + : ((await pickWorkspaceFolder( + { preSelectedWorkspace: options?.workspaceFolder }, + context, + )) as WorkspaceFolder | undefined); } catch (ex) { if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { return ex; @@ -182,6 +187,9 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { ); let existingVenvAction: ExistingVenvAction | undefined; + if (bypassQuickPicks) { + existingVenvAction = ExistingVenvAction.Create; + } const existingEnvStep = new MultiStepNode( workspaceStep, async (context?: MultiStepAction) => { @@ -204,7 +212,7 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { ); workspaceStep.next = existingEnvStep; - let interpreter: string | undefined; + let interpreter = options?.interpreter; const interpreterStep = new MultiStepNode( existingEnvStep, async (context?: MultiStepAction) => { @@ -214,22 +222,25 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { existingVenvAction === ExistingVenvAction.Create ) { try { - interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( - workspace.uri, - (i: PythonEnvironment) => - [ - EnvironmentType.System, - EnvironmentType.MicrosoftStore, - EnvironmentType.Global, - EnvironmentType.Pyenv, - ].includes(i.envType) && i.type === undefined, // only global intepreters - { - skipRecommended: true, - showBackButton: true, - placeholder: CreateEnv.Venv.selectPythonPlaceHolder, - title: null, - }, - ); + interpreter = + interpreter && bypassQuickPicks + ? interpreter + : await this.interpreterQuickPick.getInterpreterViaQuickPick( + workspace.uri, + (i: PythonEnvironment) => + [ + EnvironmentType.System, + EnvironmentType.MicrosoftStore, + EnvironmentType.Global, + EnvironmentType.Pyenv, + ].includes(i.envType) && i.type === undefined, // only global intepreters + { + skipRecommended: true, + showBackButton: true, + placeholder: CreateEnv.Venv.selectPythonPlaceHolder, + title: null, + }, + ); } catch (ex) { if (ex === InputFlowAction.back) { return MultiStepAction.Back; @@ -362,7 +373,7 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { description: string = CreateEnv.Venv.providerDescription; - id = `${PVSC_EXTENSION_ID}:venv`; + id = VenvCreationProviderId; tools = ['Venv']; } diff --git a/src/client/pythonEnvironments/creation/types.ts b/src/client/pythonEnvironments/creation/types.ts index e317bfa6cd11..0e400c2d90f3 100644 --- a/src/client/pythonEnvironments/creation/types.ts +++ b/src/client/pythonEnvironments/creation/types.ts @@ -5,7 +5,12 @@ import { Progress, WorkspaceFolder } from 'vscode'; export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {} +/** + * The interpreter path to use for the environment creation. If not provided, will prompt the user to select one. + * If the value of `interpreter` & `workspaceFolder` & `providerId` are provided we will not prompt the user to select a provider, nor folder, nor an interpreter. + */ export interface CreateEnvironmentOptionsInternal { workspaceFolder?: WorkspaceFolder; providerId?: string; + interpreter?: string; } diff --git a/src/client/pythonEnvironments/info/pythonVersion.ts b/src/client/pythonEnvironments/info/pythonVersion.ts index 92260dbb2d3f..d61fcf14db4d 100644 --- a/src/client/pythonEnvironments/info/pythonVersion.ts +++ b/src/client/pythonEnvironments/info/pythonVersion.ts @@ -25,3 +25,11 @@ export type PythonVersion = { build: string[]; prerelease: string[]; }; + +export function isStableVersion(version: PythonVersion): boolean { + // A stable version is one that has no prerelease tags. + return ( + version.prerelease.length === 0 && + (version.build.length === 0 || (version.build.length === 1 && version.build[0] === 'final')) + ); +} diff --git a/src/client/pythonEnvironments/legacyIOC.ts b/src/client/pythonEnvironments/legacyIOC.ts index a1a1b841a16f..49df2ee03f21 100644 --- a/src/client/pythonEnvironments/legacyIOC.ts +++ b/src/client/pythonEnvironments/legacyIOC.ts @@ -46,6 +46,10 @@ const convertedKinds = new Map( }), ); +export function convertEnvInfoToPythonEnvironment(info: PythonEnvInfo): PythonEnvironment { + return convertEnvInfo(info); +} + function convertEnvInfo(info: PythonEnvInfo): PythonEnvironment { const { name, location, executable, arch, kind, version, distro, id } = info; const { filename, sysPrefix } = executable;