From f4aba535b27a66be3d24e1a45d0bf5d9702a6bac Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:10:02 -0700 Subject: [PATCH 01/10] iteration 1 --- package.json | 44 ++++- src/extension.ts | 5 +- src/features/copilotTools.ts | 194 +++++++++++++++--- src/test/copilotTools.unit.test.ts | 307 +++++++++++++++++++---------- 4 files changed, 408 insertions(+), 142 deletions(-) diff --git a/package.json b/package.json index f552f1fc..9ee4073e 100644 --- a/package.json +++ b/package.json @@ -491,22 +491,52 @@ ], "languageModelTools": [ { - "name": "python_get_packages", - "displayName": "Get Python Packages", - "modelDescription": "Returns the packages installed in the given Python file's environment. You should call this when you want to generate Python code to determine the users preferred packages. Also call this to determine if you need to provide installation instructions in a response.", - "toolReferenceName": "pythonGetPackages", + "name": "python_environment_tool", + "displayName": "Get Python Environment Information", + "modelDescription": "Returns the information about the Python environment for the given file or workspace. Information includes environment type, python version, run command and installed packages with versions.", + "toolReferenceName": "pythonGetEnvironmentInfo", "tags": [], "icon": "$(files)", + "canBeReferencedInPrompt": true, "inputSchema": { "type": "object", "properties": { - "filePath": { + "resourcePath": { "type": "string" } }, - "description": "The path to the Python file or workspace to get the installed packages for.", + "description": "The path to the Python file or workspace to get the environment information for.", "required": [ - "filePath" + "resourcePath" + ] + } + }, + { + "name": "python_install_package_tool", + "displayName": "Install Python Package", + "modelDescription": "Installs Python packages in a workspace. You should call this when you want to install packages in the user's environment.", + "toolReferenceName": "pythonInstallPackage", + "tags": [], + "icon": "$(package)", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "packageList": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The list of packages to install." + }, + "workspacePath": { + "type": "string", + "description": "The path to the Python workspace to identify which environment to install packages in." + } + }, + "required": [ + "packageList", + "workspacePath" ] } } diff --git a/src/extension.ts b/src/extension.ts index 004a9832..3bf079db 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -53,7 +53,7 @@ import { ensureCorrectVersion } from './common/extVersion'; import { ExistingProjects } from './features/creators/existingProjects'; import { AutoFindProjects } from './features/creators/autoFindProjects'; import { registerTools } from './common/lm.apis'; -import { GetPackagesTool } from './features/copilotTools'; +import { GetEnvironmentInfoTool, InstallPackageTool } from './features/copilotTools'; import { TerminalActivationImpl } from './features/terminal/terminalActivationState'; import { getEnvironmentForTerminal } from './features/terminal/utils'; @@ -107,7 +107,8 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { await refreshManagerCommand(item); diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index f139fece..9c9e28e7 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -8,26 +8,40 @@ import { PreparedToolInvocation, Uri, } from 'vscode'; -import { PythonPackageGetterApi, PythonProjectEnvironmentApi } from '../api'; +import { + PythonCommandRunConfiguration, + PythonEnvironment, + PythonEnvironmentExecutionInfo, + PythonPackageGetterApi, + PythonPackageManagementApi, + PythonProjectEnvironmentApi, +} from '../api'; import { createDeferred } from '../common/utils/deferred'; -export interface IGetActiveFile { - filePath?: string; +export interface IResourceReference { + resourcePath?: string; +} + +interface EnvironmentInfo { + type: string; // e.g. conda, venv, virtualenv, sys + version: string; + runCommand: string; + packages: string[] | string; //include versions too } /** - * A tool to get the list of installed Python packages in the active environment. + * A tool to get the information about the Python environment. */ -export class GetPackagesTool implements LanguageModelTool { +export class GetEnvironmentInfoTool implements LanguageModelTool { constructor(private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi) {} /** - * Invokes the tool to get the list of installed packages. + * 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 list of installed packages or an error message. + * @returns The result containing the information about the Python environment or an error message. */ async invoke( - options: LanguageModelToolInvocationOptions, + options: LanguageModelToolInvocationOptions, token: CancellationToken, ): Promise { const deferredReturn = createDeferred(); @@ -36,44 +50,59 @@ export class GetPackagesTool implements LanguageModelTool { deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); }); - const parameters: IGetActiveFile = options.input; + const parameters: IResourceReference = options.input; - if (parameters.filePath === undefined || parameters.filePath === '') { - throw new Error('Invalid input: filePath is required'); + if (parameters.resourcePath === undefined || parameters.resourcePath === '') { + throw new Error('Invalid input: resourcePath is required'); } - const fileUri = Uri.file(parameters.filePath); + const resourcePath: Uri = Uri.file(parameters.resourcePath); try { - const environment = await this.api.getEnvironment(fileUri); + // environment info set to default values + const envInfo: EnvironmentInfo = { + type: 'no type found', + version: 'no version found', + packages: 'no packages found', + runCommand: 'no run command found', + }; + + // environment + const environment: PythonEnvironment | undefined = await this.api.getEnvironment(resourcePath); if (!environment) { // Check if the file is a notebook or a notebook cell to throw specific error messages. - if (fileUri.fsPath.endsWith('.ipynb') || fileUri.fsPath.includes('.ipynb#')) { + if (resourcePath.fsPath.endsWith('.ipynb') || resourcePath.fsPath.includes('.ipynb#')) { throw new Error('Unable to access Jupyter kernels for notebook cells'); } - throw new Error('No environment found'); + throw new Error('No environment found for the provided resource path: ' + resourcePath.fsPath); } + + const execInfo: PythonEnvironmentExecutionInfo = environment.execInfo; + const run: PythonCommandRunConfiguration = execInfo.run; + envInfo.runCommand = run.executable + (run.args ? ` ${run.args.join(' ')}` : ''); + // TODO: check if this is the right way to get type + envInfo.type = environment.envId.managerId.split(':')[1]; + envInfo.version = environment.version; + + // does this need to be refreshed prior to returning to get any new packages? await this.api.refreshPackages(environment); const installedPackages = await this.api.getPackages(environment); - - let resultMessage: string; if (!installedPackages || installedPackages.length === 0) { - resultMessage = 'No packages are installed in the current environment.'; + envInfo.packages = []; } else { - const packageNames = installedPackages - .map((pkg) => (pkg.version ? `${pkg.name} (${pkg.version})` : pkg.name)) - .join(', '); - resultMessage = 'The packages installed in the current environment are as follows:\n' + packageNames; + envInfo.packages = installedPackages.map((pkg) => + pkg.version ? `${pkg.name} (${pkg.version})` : pkg.name, + ); } - const textPart = new LanguageModelTextPart(resultMessage || ''); + // format and return + const textPart = BuildEnvironmentInfoContent(envInfo); deferredReturn.resolve({ content: [textPart] }); } catch (error) { - const errorMessage: string = `An error occurred while fetching packages: ${error}`; + const errorMessage: string = `An error occurred while fetching environment information: ${error}`; deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); } return deferredReturn.promise; } - /** * Prepares the invocation of the tool. * @param _options - The preparation options. @@ -81,10 +110,121 @@ export class GetPackagesTool implements LanguageModelTool { * @returns The prepared tool invocation. */ async prepareInvocation?( - _options: LanguageModelToolInvocationPrepareOptions, + _options: LanguageModelToolInvocationPrepareOptions, _token: CancellationToken, ): Promise { - const message = 'Preparing to fetch the list of installed Python packages...'; + const message = 'Preparing to fetch Python environment information...'; + return { + invocationMessage: message, + }; + } +} + +function BuildEnvironmentInfoContent(envInfo: EnvironmentInfo): LanguageModelTextPart { + // Create a formatted string that looks like JSON but preserves comments + const content = `{ + // type of python environment; sys means it is the system python + "environmentType": ${JSON.stringify(envInfo.type)}, + // python version of the environment + "pythonVersion": ${JSON.stringify(envInfo.version)}, + // command to run python in this environment, will include command with active environment if applicable + "runCommand": ${JSON.stringify(envInfo.runCommand)}, + // installed python packages and their versions if know in the format (), empty array is returned if no packages are installed. + "packages": ${JSON.stringify(Array.isArray(envInfo.packages) ? envInfo.packages : envInfo.packages, null, 2)} +}`; + + return new LanguageModelTextPart(content); +} + +/** + * The input interface for the Install Package Tool. + */ +export interface IInstallPackageInput { + packageList: string[]; + workspacePath?: string; +} + +/** + * A tool to install Python packages in the active environment. + */ +export class InstallPackageTool implements LanguageModelTool { + constructor( + private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi, + ) {} + + /** + * Invokes the tool to install Python packages in the active environment. + * @param options - The invocation options containing the package list. + * @param token - The cancellation token. + * @returns The result containing the installation status or an error message. + */ + async invoke( + options: LanguageModelToolInvocationOptions, + token: CancellationToken, + ): Promise { + const deferredReturn = createDeferred(); + token.onCancellationRequested(() => { + const errorMessage: string = `Operation cancelled by the user.`; + deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); + }); + + const parameters: IInstallPackageInput = options.input; + const workspacePath = parameters.workspacePath ? Uri.file(parameters.workspacePath) : undefined; + if (!workspacePath) { + throw new Error('Invalid input: workspacePath is required'); + } + + if (!parameters.packageList || parameters.packageList.length === 0) { + throw new Error('Invalid input: packageList is required and cannot be empty'); + } + const packageCount = parameters.packageList.length; + const packagePlurality = packageCount === 1 ? 'package' : 'packages'; + + try { + const environment = await this.api.getEnvironment(workspacePath); + if (!environment) { + // Check if the file is a notebook or a notebook cell to throw specific error messages. + if (workspacePath.fsPath.endsWith('.ipynb') || workspacePath.fsPath.includes('.ipynb#')) { + throw new Error('Unable to access Jupyter kernels for notebook cells'); + } + throw new Error('No environment found'); + } + + // Install the packages + await this.api.installPackages(environment, parameters.packageList); + const resultMessage = `Successfully installed ${packagePlurality}: ${parameters.packageList.join(', ')}`; + + // Refresh packages after installation to update the package view + //TODO: do I want the await? + await this.api.refreshPackages(environment); + + deferredReturn.resolve({ + content: [new LanguageModelTextPart(resultMessage)], + }); + } catch (error) { + const errorMessage = `An error occurred while installing ${packagePlurality}: ${error}`; + + deferredReturn.resolve({ content: [new LanguageModelTextPart(errorMessage)] } as LanguageModelToolResult); + } + + return deferredReturn.promise; + } + + /** + * Prepares the invocation of the tool. + * @param options - The preparation options. + * @param _token - The cancellation token. + * @returns The prepared tool invocation. + */ + async prepareInvocation?( + options: LanguageModelToolInvocationPrepareOptions, + _token: CancellationToken, + ): Promise { + const packageList = options.input.packageList || []; + const packageCount = packageList.length; + const packageText = packageCount === 1 ? 'package' : 'packages'; + const message = `Preparing to install Python ${packageText}: ${packageList.join(', ')}...`; + return { invocationMessage: message, }; diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index 2857fa56..eea1c531 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -2,18 +2,31 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Package, PythonEnvironment, PythonPackageGetterApi, PythonProjectEnvironmentApi } from '../api'; +import { + PythonCommandRunConfiguration, + PythonEnvironment, + PythonPackageGetterApi, + PythonPackageManagementApi, + PythonProjectEnvironmentApi, +} from '../api'; import { createDeferred } from '../common/utils/deferred'; -import { GetPackagesTool, IGetActiveFile } from '../features/copilotTools'; - -suite('GetPackagesTool Tests', () => { - let tool: GetPackagesTool; - let mockApi: typeMoq.IMock; +import { + GetEnvironmentInfoTool, + IInstallPackageInput, + InstallPackageTool, + IResourceReference, +} from '../features/copilotTools'; + +suite('InstallPackageTool Tests', () => { + let installPackageTool: InstallPackageTool; + let mockApi: typeMoq.IMock; let mockEnvironment: typeMoq.IMock; setup(() => { // Create mock functions - mockApi = typeMoq.Mock.ofType(); + mockApi = typeMoq.Mock.ofType< + PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi + >(); mockEnvironment = typeMoq.Mock.ofType(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -22,190 +35,272 @@ suite('GetPackagesTool Tests', () => { // refresh will always return a resolved promise mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - // Create an instance of GetPackagesTool with the mock functions - tool = new GetPackagesTool(mockApi.object); + // Create an instance of InstallPackageTool with the mock functions + installPackageTool = new InstallPackageTool(mockApi.object); }); teardown(() => { sinon.restore(); }); - test('should throw error if filePath is undefined', async () => { - const testFile: IGetActiveFile = { - filePath: '', + test('should throw error if workspacePath is an empty string', async () => { + const testFile: IInstallPackageInput = { + workspacePath: '', + packageList: ['package1', 'package2'], }; const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - await assert.rejects(tool.invoke(options, token), { message: 'Invalid input: filePath is required' }); + await assert.rejects(installPackageTool.invoke(options, token), { + message: 'Invalid input: workspacePath is required', + }); }); test('should throw error for notebook files', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockEnvironment.setup((x: any) => x.then).returns(() => undefined); - const testFile: IGetActiveFile = { - filePath: 'test.ipynb', + const testFile: IInstallPackageInput = { + workspacePath: 'this/is/a/test/path.ipynb', + packageList: ['package1', 'package2'], }; const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - const result = await tool.invoke(options, token); + const result = await installPackageTool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.LanguageModelTextPart; - assert.strictEqual( - firstPart.value, - 'An error occurred while fetching packages: Error: Unable to access Jupyter kernels for notebook cells', - ); + assert.strictEqual(firstPart.value.includes('An error occurred while installing packages'), true); }); test('should throw error for notebook cells', async () => { - const testFile: IGetActiveFile = { - filePath: 'test.ipynb#123', + const testFile: IInstallPackageInput = { + workspacePath: 'this/is/a/test/path.ipynb#cell', + packageList: ['package1', 'package2'], }; const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - const result = await tool.invoke(options, token); + const result = await installPackageTool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; - const firstPart = content[0] as vscode.MarkdownString; + const firstPart = content[0] as vscode.LanguageModelTextPart; - assert.strictEqual( - firstPart.value, - 'An error occurred while fetching packages: Error: Unable to access Jupyter kernels for notebook cells', - ); + assert.strictEqual(firstPart.value.includes('An error occurred while installing packages'), true); }); - test('should return no packages message if no packages are installed', async () => { - const testFile: IGetActiveFile = { - filePath: 'test.py', + test('should throw error if packageList passed in is empty', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: [], + }; + + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + await assert.rejects(installPackageTool.invoke(options, token), { + message: 'Invalid input: packageList is required and cannot be empty', + }); + }); + + test('should handle cancellation', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: ['package1', 'package2'], }; mockApi .setup((x) => x.getEnvironment(typeMoq.It.isAny())) - .returns(() => { + .returns(async () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve([])); + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); const options = { input: testFile, toolInvocationToken: undefined }; - const token = new vscode.CancellationTokenSource().token; - const result = await tool.invoke(options, token); - const content = result.content as vscode.LanguageModelTextPart[]; - const firstPart = content[0] as vscode.MarkdownString; + const tokenSource = new vscode.CancellationTokenSource(); + const token = tokenSource.token; - assert.strictEqual(firstPart.value, 'No packages are installed in the current environment.'); + const deferred = createDeferred(); + installPackageTool.invoke(options, token).then((result) => { + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + + assert.strictEqual(firstPart.value, 'Operation cancelled by the user.'); + deferred.resolve(); + }); + + tokenSource.cancel(); + await deferred.promise; }); - test('should return just packages if versions do not exist', async () => { - const testFile: IGetActiveFile = { - filePath: 'test.py', + test('should handle packages installation', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: ['package1', 'package2'], }; mockApi .setup((x) => x.getEnvironment(typeMoq.It.isAny())) - .returns(() => { + .returns(async () => { return Promise.resolve(mockEnvironment.object); }); - const mockPackages: Package[] = [ - { - pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, - name: 'package1', - displayName: 'package1', - }, - { - pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, - name: 'package2', - displayName: 'package2', - }, - ]; - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve(mockPackages)); + mockApi + .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + const deferred = createDeferred(); + deferred.resolve(); + return deferred.promise; + }); const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - const result = await tool.invoke(options, token); + + const result = await installPackageTool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - assert.ok( - firstPart.value.includes('The packages installed in the current environment are as follows:') && - firstPart.value.includes('package1') && - firstPart.value.includes('package2'), - ); + assert.strictEqual(firstPart.value.includes('Successfully installed packages'), true); + assert.strictEqual(firstPart.value.includes('package1'), true); + assert.strictEqual(firstPart.value.includes('package2'), true); }); - - test('should return installed packages with versions', async () => { - const testFile: IGetActiveFile = { - filePath: 'test.py', + test('should handle package installation failure', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: ['package1', 'package2'], }; mockApi .setup((x) => x.getEnvironment(typeMoq.It.isAny())) - .returns(() => { + .returns(async () => { return Promise.resolve(mockEnvironment.object); }); - const mockPackages: Package[] = [ - { - pkgId: { id: 'pkg1', managerId: 'pip', environmentId: 'env1' }, - name: 'package1', - displayName: 'package1', - version: '1.0.0', - }, - { - pkgId: { id: 'pkg2', managerId: 'pip', environmentId: 'env1' }, - name: 'package2', - displayName: 'package2', - version: '2.0.0', - }, - ]; - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve(mockPackages)); + mockApi + .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + const deferred = createDeferred(); + deferred.reject(new Error('Installation failed')); + return deferred.promise; + }); const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - const result = await tool.invoke(options, token); + + const result = await installPackageTool.invoke(options, token); const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - assert.ok( - firstPart.value.includes('The packages installed in the current environment are as follows:') && - firstPart.value.includes('package1 (1.0.0)') && - firstPart.value.includes('package2 (2.0.0)'), + console.log('result', firstPart.value); + assert.strictEqual( + firstPart.value.includes('An error occurred while installing packages'), + true, + `error message was ${firstPart.value}`, ); }); - - test('should handle cancellation', async () => { - const testFile: IGetActiveFile = { - filePath: 'test.py', + test('should handle error occurs when getting environment', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: ['package1', 'package2'], }; + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.reject(new Error('Unable to get environment')); + }); + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + const result = await installPackageTool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + assert.strictEqual(firstPart.value.includes('An error occurred while installing packages'), true); + }); + test('correct plurality in package installation message', async () => { + const testFile: IInstallPackageInput = { + workspacePath: 'path/to/workspace', + packageList: ['package1'], + }; mockApi .setup((x) => x.getEnvironment(typeMoq.It.isAny())) .returns(async () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - mockApi.setup((x) => x.getPackages(typeMoq.It.isAny())).returns(() => Promise.resolve([])); - + mockApi + .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + const deferred = createDeferred(); + deferred.resolve(); + return deferred.promise; + }); const options = { input: testFile, toolInvocationToken: undefined }; - const tokenSource = new vscode.CancellationTokenSource(); - const token = tokenSource.token; + const token = new vscode.CancellationTokenSource().token; + const result = await installPackageTool.invoke(options, token); + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + assert.strictEqual(firstPart.value.includes('packages'), false); + assert.strictEqual(firstPart.value.includes('package'), true); + }); +}); - const deferred = createDeferred(); - tool.invoke(options, token).then((result) => { - const content = result.content as vscode.LanguageModelTextPart[]; - const firstPart = content[0] as vscode.MarkdownString; +suite('GetEnvironmentInfoTool Tests', () => { + let getEnvironmentInfoTool: GetEnvironmentInfoTool; + let mockApi: typeMoq.IMock; + let mockEnvironment: typeMoq.IMock; - assert.strictEqual(firstPart.value, 'Operation cancelled by the user.'); - deferred.resolve(); - }); + setup(() => { + // Create mock functions + mockApi = typeMoq.Mock.ofType< + PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi + >(); + mockEnvironment = typeMoq.Mock.ofType(); - tokenSource.cancel(); - await deferred.promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironment.setup((x: any) => x.then).returns(() => undefined); + + // Create an instance of GetEnvironmentInfoTool with the mock functions + getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object); + + // runConfig valid / not valid + const runConfigValid: PythonCommandRunConfiguration = { + executable: 'conda', + args: ['run', '-n', 'env_name', 'python'], + }; + const runConfigValidString = 'conda run -n env_name python'; + const runConfigNoArgs: PythonCommandRunConfiguration = { + executable: '.venv/bin/python', + args: [], + }; + const runConfigNoArgsString = '.venv/bin/python'; + + // managerId valid / not valid + const managerIdValid = `'ms-python.python:venv'`; + const typeValidString = 'venv'; + const managerIdInvalid = `vscode-python, there is no such manager`; + + // environment valid + const envInfoVersion = '3.9.1'; + + //package valid / not valid + const installedPackagesValid = [{ name: 'package1', version: '1.0.0' }, { name: 'package2' }]; + const installedPackagesValidString = 'package1 1.0.0\npackage2 2.0.0'; + const installedPackagesInvalid = undefined; + }); + + teardown(() => { + sinon.restore(); + }); + test('should throw error if resourcePath is an empty string', async () => { + const testFile: IResourceReference = { + resourcePath: '', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + await assert.rejects(getEnvironmentInfoTool.invoke(options, token), { + message: 'Invalid input: resourcePath is required', + }); }); + // test should throw error if environment is not found + // + // cancellation token should work if called }); From f944ad7719c1ddc7256df31613c7e85bd34616b6 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:07:44 -0700 Subject: [PATCH 02/10] additions to tests --- src/test/copilotTools.unit.test.ts | 133 +++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 27 deletions(-) diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index eea1c531..3c80dc31 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -3,8 +3,10 @@ import * as vscode from 'vscode'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; import { - PythonCommandRunConfiguration, + Package, + PackageId, PythonEnvironment, + PythonEnvironmentId, PythonPackageGetterApi, PythonPackageManagementApi, PythonProjectEnvironmentApi, @@ -262,29 +264,29 @@ suite('GetEnvironmentInfoTool Tests', () => { getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object); // runConfig valid / not valid - const runConfigValid: PythonCommandRunConfiguration = { - executable: 'conda', - args: ['run', '-n', 'env_name', 'python'], - }; - const runConfigValidString = 'conda run -n env_name python'; - const runConfigNoArgs: PythonCommandRunConfiguration = { - executable: '.venv/bin/python', - args: [], - }; - const runConfigNoArgsString = '.venv/bin/python'; - - // managerId valid / not valid - const managerIdValid = `'ms-python.python:venv'`; - const typeValidString = 'venv'; - const managerIdInvalid = `vscode-python, there is no such manager`; - - // environment valid - const envInfoVersion = '3.9.1'; - - //package valid / not valid - const installedPackagesValid = [{ name: 'package1', version: '1.0.0' }, { name: 'package2' }]; - const installedPackagesValidString = 'package1 1.0.0\npackage2 2.0.0'; - const installedPackagesInvalid = undefined; + // const runConfigValid: PythonCommandRunConfiguration = { + // executable: 'conda', + // args: ['run', '-n', 'env_name', 'python'], + // }; + // const runConfigValidString = 'conda run -n env_name python'; + // const runConfigNoArgs: PythonCommandRunConfiguration = { + // executable: '.venv/bin/python', + // args: [], + // }; + // const runConfigNoArgsString = '.venv/bin/python'; + + // // managerId valid / not valid + // const managerIdValid = `'ms-python.python:venv'`; + // const typeValidString = 'venv'; + // const managerIdInvalid = `vscode-python, there is no such manager`; + + // // environment valid + // const envInfoVersion = '3.9.1'; + + // //package valid / not valid + // const installedPackagesValid = [{ name: 'package1', version: '1.0.0' }, { name: 'package2' }]; + // const installedPackagesValidString = 'package1 1.0.0\npackage2 2.0.0'; + // const installedPackagesInvalid = undefined; }); teardown(() => { @@ -300,7 +302,84 @@ suite('GetEnvironmentInfoTool Tests', () => { message: 'Invalid input: resourcePath is required', }); }); - // test should throw error if environment is not found - // - // cancellation token should work if called + test('should throw error if environment is not found', async () => { + const testFile: IResourceReference = { + resourcePath: 'this/is/a/test/path.ipynb', + }; + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.reject(new Error('Unable to get environment')); + }); + + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + await assert.rejects(getEnvironmentInfoTool.invoke(options, token), { + message: 'Unable to get environment', + }); + }); + test('should return successful with environment info', async () => { + // create mock of PythonEnvironment + const mockEnvironmentSuccess = typeMoq.Mock.ofType(); + // mockEnvironment = typeMoq.Mock.ofType(); + + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironmentSuccess.setup((x: any) => x.then).returns(() => undefined); + mockEnvironmentSuccess.setup((x) => x.version).returns(() => '3.9.1'); + const mockEnvId = typeMoq.Mock.ofType(); + mockEnvId.setup((x) => x.managerId).returns(() => 'ms-python.python:venv'); + mockEnvironmentSuccess.setup((x) => x.envId).returns(() => mockEnvId.object); + mockEnvironmentSuccess + .setup((x) => x.execInfo) + .returns(() => ({ + run: { + executable: 'conda', + args: ['run', '-n', 'env_name', 'python'], + }, + })); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve(mockEnvironmentSuccess.object); + }); + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve(mockEnvironmentSuccess.object); + }); + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + + const packageAId: PackageId = { + id: 'package1', + managerId: 'ms-python.python:venv', + environmentId: 'env_id', + }; + const packageBId: PackageId = { + id: 'package2', + managerId: 'ms-python.python:venv', + environmentId: 'env_id', + }; + const packageA: Package = { name: 'package1', displayName: 'Package 1', version: '1.0.0', pkgId: packageAId }; + const packageB: Package = { name: 'package2', displayName: 'Package 2', version: '2.0.0', pkgId: packageBId }; + mockApi + .setup((x) => x.getPackages(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve([packageA, packageB]); + }); + + const testFile: IResourceReference = { + resourcePath: 'this/is/a/test/path.ipynb', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + // run + const result = await getEnvironmentInfoTool.invoke(options, token); + // assert + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + console.log('result', firstPart.value); + assert.strictEqual(firstPart.value.includes('Python version: 3.9.1'), true); + assert.strictEqual(firstPart.value, ''); + }); }); From dd6e4b4472ac29944a775557f93c0632504e940a Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:03:36 -0700 Subject: [PATCH 03/10] add tests & handle empty array --- src/features/copilotTools.ts | 2 +- src/test/copilotTools.unit.test.ts | 61 ++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 9c9e28e7..3945c602 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -78,7 +78,7 @@ export class GetEnvironmentInfoTool implements LanguageModelTool 0 ? ` ${run.args.join(' ')}` : ''); // TODO: check if this is the right way to get type envInfo.type = environment.envId.managerId.split(':')[1]; envInfo.version = environment.version; diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index 3c80dc31..6654dc83 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -338,11 +338,6 @@ suite('GetEnvironmentInfoTool Tests', () => { }, })); - mockApi - .setup((x) => x.getEnvironment(typeMoq.It.isAny())) - .returns(async () => { - return Promise.resolve(mockEnvironmentSuccess.object); - }); mockApi .setup((x) => x.getEnvironment(typeMoq.It.isAny())) .returns(async () => { @@ -379,7 +374,59 @@ suite('GetEnvironmentInfoTool Tests', () => { const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; console.log('result', firstPart.value); - assert.strictEqual(firstPart.value.includes('Python version: 3.9.1'), true); - assert.strictEqual(firstPart.value, ''); + assert.strictEqual(firstPart.value.includes('3.9.1'), true); + assert.strictEqual(firstPart.value.includes('package1 (1.0.0)'), true); + assert.strictEqual(firstPart.value.includes('package2 (2.0.0)'), true); + assert.strictEqual(firstPart.value.includes(`"conda run -n env_name python"`), true); + assert.strictEqual(firstPart.value.includes('venv'), true); + }); + test('should return successful with weird environment info', async () => { + // create mock of PythonEnvironment + const mockEnvironmentSuccess = typeMoq.Mock.ofType(); + // mockEnvironment = typeMoq.Mock.ofType(); + + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockEnvironmentSuccess.setup((x: any) => x.then).returns(() => undefined); + mockEnvironmentSuccess.setup((x) => x.version).returns(() => '3.12.1'); + const mockEnvId = typeMoq.Mock.ofType(); + mockEnvId.setup((x) => x.managerId).returns(() => 'ms-python.python:sys'); + mockEnvironmentSuccess.setup((x) => x.envId).returns(() => mockEnvId.object); + mockEnvironmentSuccess + .setup((x) => x.execInfo) + .returns(() => ({ + run: { + executable: 'path/to/venv/bin/python', + args: [], + }, + })); + + mockApi + .setup((x) => x.getEnvironment(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve(mockEnvironmentSuccess.object); + }); + mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); + + mockApi + .setup((x) => x.getPackages(typeMoq.It.isAny())) + .returns(async () => { + return Promise.resolve([]); + }); + + const testFile: IResourceReference = { + resourcePath: 'this/is/a/test/path.ipynb', + }; + const options = { input: testFile, toolInvocationToken: undefined }; + const token = new vscode.CancellationTokenSource().token; + // run + const result = await getEnvironmentInfoTool.invoke(options, token); + // assert + const content = result.content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + console.log('result', firstPart.value); + assert.strictEqual(firstPart.value.includes('3.12.1'), true); + assert.strictEqual(firstPart.value.includes('"packages": []'), true); + assert.strictEqual(firstPart.value.includes(`"path/to/venv/bin/python"`), true); + assert.strictEqual(firstPart.value.includes('sys'), true); }); }); From 27d3ac57cbfb81bea846ca8aac41f1c48379c0c5 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:10:28 -0700 Subject: [PATCH 04/10] fix test --- src/test/copilotTools.unit.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index 6654dc83..63a64fda 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -314,9 +314,10 @@ suite('GetEnvironmentInfoTool Tests', () => { const options = { input: testFile, toolInvocationToken: undefined }; const token = new vscode.CancellationTokenSource().token; - await assert.rejects(getEnvironmentInfoTool.invoke(options, token), { - message: 'Unable to get environment', - }); + const result = getEnvironmentInfoTool.invoke(options, token); + const content = (await result).content as vscode.LanguageModelTextPart[]; + const firstPart = content[0] as vscode.MarkdownString; + assert.strictEqual(firstPart.value.includes('An error occurred while fetching environment information'), true); }); test('should return successful with environment info', async () => { // create mock of PythonEnvironment From 067556553b3ac7c5ff84a32f29a536e9072eea12 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:37:12 -0700 Subject: [PATCH 05/10] updates based on karthik feedback --- src/features/copilotTools.ts | 38 ++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 3945c602..9fad8ee6 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -57,33 +57,35 @@ export class GetEnvironmentInfoTool implements LanguageModelTool 0 ? ` ${run.args.join(' ')}` : ''); - // TODO: check if this is the right way to get type - envInfo.type = environment.envId.managerId.split(':')[1]; envInfo.version = environment.version; - // does this need to be refreshed prior to returning to get any new packages? + // get the environment type or manager if type is not available + try { + envInfo.type = + environment.envId.managerId?.split(':')[1] || environment.envId.managerId || 'cannot be determined'; + } catch { + envInfo.type = environment.envId.managerId || 'cannot be determined'; + } + + // refresh and get packages await this.api.refreshPackages(environment); const installedPackages = await this.api.getPackages(environment); if (!installedPackages || installedPackages.length === 0) { @@ -99,7 +101,9 @@ export class GetEnvironmentInfoTool implements LanguageModelTool (), empty array is returned if no packages are installed. "packages": ${JSON.stringify(Array.isArray(envInfo.packages) ? envInfo.packages : envInfo.packages, null, 2)} From b1bf868947273680cb78bae74200e5d2a5d40c7d Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:53:58 -0700 Subject: [PATCH 06/10] updates based on feedback --- package.json | 6 +++--- src/extension.ts | 2 +- src/features/copilotTools.ts | 30 ++++++++++++++++++++---------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 9ee4073e..df434d44 100644 --- a/package.json +++ b/package.json @@ -493,7 +493,7 @@ { "name": "python_environment_tool", "displayName": "Get Python Environment Information", - "modelDescription": "Returns the information about the Python environment for the given file or workspace. Information includes environment type, python version, run command and installed packages with versions.", + "modelDescription": "Provides details about the Python environment for a specified file or workspace, including environment type, Python version, run command, and installed packages with their versions.", "toolReferenceName": "pythonGetEnvironmentInfo", "tags": [], "icon": "$(files)", @@ -514,7 +514,7 @@ { "name": "python_install_package_tool", "displayName": "Install Python Package", - "modelDescription": "Installs Python packages in a workspace. You should call this when you want to install packages in the user's environment.", + "modelDescription": "Installs Python packages in the given workspace. Use this tool to install packages in the user's chosen environment.", "toolReferenceName": "pythonInstallPackage", "tags": [], "icon": "$(package)", @@ -531,7 +531,7 @@ }, "workspacePath": { "type": "string", - "description": "The path to the Python workspace to identify which environment to install packages in." + "description": "Path to Python workspace that determines the environment for package installation." } }, "required": [ diff --git a/src/extension.ts b/src/extension.ts index 3bf079db..7315370f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -107,7 +107,7 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshManager', async (item) => { diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 9fad8ee6..0fd6cc15 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -9,14 +9,17 @@ import { Uri, } from 'vscode'; import { + EnvironmentManager, PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentExecutionInfo, + PythonEnvironmentManagementApi, PythonPackageGetterApi, PythonPackageManagementApi, PythonProjectEnvironmentApi, } from '../api'; import { createDeferred } from '../common/utils/deferred'; +import { EnvironmentManagers } from '../internal.api'; export interface IResourceReference { resourcePath?: string; @@ -33,7 +36,10 @@ interface EnvironmentInfo { * A tool to get the information about the Python environment. */ export class GetEnvironmentInfoTool implements LanguageModelTool { - constructor(private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi) {} + constructor( + private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi, + private readonly envManagers: EnvironmentManagers, + ) {} /** * Invokes the tool to get the information about the Python environment. * @param options - The invocation options containing the file path. @@ -79,13 +85,14 @@ export class GetEnvironmentInfoTool implements LanguageModelTool (), empty array is returned if no packages are installed. "packages": ${JSON.stringify(Array.isArray(envInfo.packages) ? envInfo.packages : envInfo.packages, null, 2)} @@ -198,9 +210,7 @@ export class InstallPackageTool implements LanguageModelTool Date: Thu, 10 Apr 2025 12:20:40 -0700 Subject: [PATCH 07/10] fix comments p2 --- src/features/copilotTools.ts | 10 +++------- src/test/copilotTools.unit.test.ts | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 0fd6cc15..530887c3 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -9,11 +9,8 @@ import { Uri, } from 'vscode'; import { - EnvironmentManager, - PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentExecutionInfo, - PythonEnvironmentManagementApi, PythonPackageGetterApi, PythonPackageManagementApi, PythonProjectEnvironmentApi, @@ -79,8 +76,9 @@ export class GetEnvironmentInfoTool implements LanguageModelTool 0 ? ` ${run.args.join(' ')}` : ''); + const executable = execInfo?.activatedRun?.executable ?? execInfo?.run.executable ?? 'python'; + const args = execInfo?.activatedRun?.args ?? execInfo?.run.args ?? []; + envInfo.runCommand = args.length > 0 ? `${executable} ${args.join(' ')}` : executable; envInfo.version = environment.version; // get the environment type or manager if type is not available @@ -210,8 +208,6 @@ export class InstallPackageTool implements LanguageModelTool { let installPackageTool: InstallPackageTool; @@ -249,6 +250,8 @@ suite('GetEnvironmentInfoTool Tests', () => { let getEnvironmentInfoTool: GetEnvironmentInfoTool; let mockApi: typeMoq.IMock; let mockEnvironment: typeMoq.IMock; + let em: typeMoq.IMock; + let manager: typeMoq.IMock; setup(() => { // Create mock functions @@ -261,7 +264,16 @@ suite('GetEnvironmentInfoTool Tests', () => { mockEnvironment.setup((x: any) => x.then).returns(() => undefined); // Create an instance of GetEnvironmentInfoTool with the mock functions - getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object); + manager = typeMoq.Mock.ofType(); + manager.setup((m) => m.id).returns(() => 'ms-python.python:venv'); + manager.setup((m) => m.name).returns(() => 'venv'); + manager.setup((m) => m.displayName).returns(() => 'Test Manager'); + + em = typeMoq.Mock.ofType(); + em.setup((e) => e.managers).returns(() => [manager.object]); + em.setup((e) => e.getEnvironmentManager(typeMoq.It.isAnyString())).returns(() => manager.object); + + getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object, em.object); // runConfig valid / not valid // const runConfigValid: PythonCommandRunConfiguration = { @@ -390,7 +402,8 @@ suite('GetEnvironmentInfoTool Tests', () => { mockEnvironmentSuccess.setup((x: any) => x.then).returns(() => undefined); mockEnvironmentSuccess.setup((x) => x.version).returns(() => '3.12.1'); const mockEnvId = typeMoq.Mock.ofType(); - mockEnvId.setup((x) => x.managerId).returns(() => 'ms-python.python:sys'); + mockEnvId.setup((x) => x.managerId).returns(() => 'ms-python.python:system'); + manager.setup((m) => m.name).returns(() => 'system'); mockEnvironmentSuccess.setup((x) => x.envId).returns(() => mockEnvId.object); mockEnvironmentSuccess .setup((x) => x.execInfo) From 42b36d0f21bc6d9f4030aa36d9fcbebae2c45a1d Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 10 Apr 2025 12:25:24 -0700 Subject: [PATCH 08/10] update to new package api --- src/features/copilotTools.ts | 6 +++++- src/test/copilotTools.unit.test.ts | 14 +++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 530887c3..4d78a11a 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -9,6 +9,7 @@ import { Uri, } from 'vscode'; import { + PackageManagementOptions, PythonEnvironment, PythonEnvironmentExecutionInfo, PythonPackageGetterApi, @@ -205,7 +206,10 @@ export class InstallPackageTool implements LanguageModelTool { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockEnvironment.setup((x: any) => x.then).returns(() => undefined); - // refresh will always return a resolved promise - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - // Create an instance of InstallPackageTool with the mock functions installPackageTool = new InstallPackageTool(mockApi.object); }); @@ -114,8 +111,6 @@ suite('InstallPackageTool Tests', () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); - const options = { input: testFile, toolInvocationToken: undefined }; const tokenSource = new vscode.CancellationTokenSource(); const token = tokenSource.token; @@ -145,9 +140,8 @@ suite('InstallPackageTool Tests', () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); mockApi - .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((x) => x.managePackages(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { const deferred = createDeferred(); deferred.resolve(); @@ -177,9 +171,8 @@ suite('InstallPackageTool Tests', () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); mockApi - .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((x) => x.managePackages(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { const deferred = createDeferred(); deferred.reject(new Error('Installation failed')); @@ -228,9 +221,8 @@ suite('InstallPackageTool Tests', () => { .returns(async () => { return Promise.resolve(mockEnvironment.object); }); - mockApi.setup((x) => x.refreshPackages(typeMoq.It.isAny())).returns(() => Promise.resolve()); mockApi - .setup((x) => x.installPackages(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((x) => x.managePackages(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { const deferred = createDeferred(); deferred.resolve(); From 193b72e8305f26197d1552ed174e0a9f119e6ddd Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 10 Apr 2025 13:03:21 -0700 Subject: [PATCH 09/10] test updates --- src/features/copilotTools.ts | 15 ++++--- src/test/copilotTools.unit.test.ts | 70 ++++++++++++------------------ 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 4d78a11a..6a5adad1 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -132,19 +132,20 @@ export class GetEnvironmentInfoTool implements LanguageModelTool (), empty array is returned if no packages are installed. + // Installed Python packages, each in the format or (). The version may be omitted if unknown. Returns an empty array if no packages are installed. "packages": ${JSON.stringify(Array.isArray(envInfo.packages) ? envInfo.packages : envInfo.packages, null, 2)} }`; diff --git a/src/test/copilotTools.unit.test.ts b/src/test/copilotTools.unit.test.ts index 54e42e8c..03eb936d 100644 --- a/src/test/copilotTools.unit.test.ts +++ b/src/test/copilotTools.unit.test.ts @@ -186,7 +186,6 @@ suite('InstallPackageTool Tests', () => { const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - console.log('result', firstPart.value); assert.strictEqual( firstPart.value.includes('An error occurred while installing packages'), true, @@ -243,7 +242,7 @@ suite('GetEnvironmentInfoTool Tests', () => { let mockApi: typeMoq.IMock; let mockEnvironment: typeMoq.IMock; let em: typeMoq.IMock; - let manager: typeMoq.IMock; + let managerSys: typeMoq.IMock; setup(() => { // Create mock functions @@ -255,42 +254,11 @@ suite('GetEnvironmentInfoTool Tests', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockEnvironment.setup((x: any) => x.then).returns(() => undefined); - // Create an instance of GetEnvironmentInfoTool with the mock functions - manager = typeMoq.Mock.ofType(); - manager.setup((m) => m.id).returns(() => 'ms-python.python:venv'); - manager.setup((m) => m.name).returns(() => 'venv'); - manager.setup((m) => m.displayName).returns(() => 'Test Manager'); - em = typeMoq.Mock.ofType(); - em.setup((e) => e.managers).returns(() => [manager.object]); - em.setup((e) => e.getEnvironmentManager(typeMoq.It.isAnyString())).returns(() => manager.object); + em.setup((e) => e.managers).returns(() => [managerSys.object]); + em.setup((e) => e.getEnvironmentManager(typeMoq.It.isAnyString())).returns(() => managerSys.object); getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object, em.object); - - // runConfig valid / not valid - // const runConfigValid: PythonCommandRunConfiguration = { - // executable: 'conda', - // args: ['run', '-n', 'env_name', 'python'], - // }; - // const runConfigValidString = 'conda run -n env_name python'; - // const runConfigNoArgs: PythonCommandRunConfiguration = { - // executable: '.venv/bin/python', - // args: [], - // }; - // const runConfigNoArgsString = '.venv/bin/python'; - - // // managerId valid / not valid - // const managerIdValid = `'ms-python.python:venv'`; - // const typeValidString = 'venv'; - // const managerIdInvalid = `vscode-python, there is no such manager`; - - // // environment valid - // const envInfoVersion = '3.9.1'; - - // //package valid / not valid - // const installedPackagesValid = [{ name: 'package1', version: '1.0.0' }, { name: 'package2' }]; - // const installedPackagesValidString = 'package1 1.0.0\npackage2 2.0.0'; - // const installedPackagesInvalid = undefined; }); teardown(() => { @@ -324,11 +292,19 @@ suite('GetEnvironmentInfoTool Tests', () => { assert.strictEqual(firstPart.value.includes('An error occurred while fetching environment information'), true); }); test('should return successful with environment info', async () => { + // Create an instance of GetEnvironmentInfoTool with the mock functions + managerSys = typeMoq.Mock.ofType(); + managerSys.setup((m) => m.id).returns(() => 'ms-python.python:venv'); + managerSys.setup((m) => m.name).returns(() => 'venv'); + managerSys.setup((m) => m.displayName).returns(() => 'Test Manager'); + + em = typeMoq.Mock.ofType(); + em.setup((e) => e.managers).returns(() => [managerSys.object]); + em.setup((e) => e.getEnvironmentManager(typeMoq.It.isAnyString())).returns(() => managerSys.object); // create mock of PythonEnvironment const mockEnvironmentSuccess = typeMoq.Mock.ofType(); - // mockEnvironment = typeMoq.Mock.ofType(); - // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any mockEnvironmentSuccess.setup((x: any) => x.then).returns(() => undefined); mockEnvironmentSuccess.setup((x) => x.version).returns(() => '3.9.1'); const mockEnvId = typeMoq.Mock.ofType(); @@ -378,7 +354,6 @@ suite('GetEnvironmentInfoTool Tests', () => { // assert const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - console.log('result', firstPart.value); assert.strictEqual(firstPart.value.includes('3.9.1'), true); assert.strictEqual(firstPart.value.includes('package1 (1.0.0)'), true); assert.strictEqual(firstPart.value.includes('package2 (2.0.0)'), true); @@ -388,14 +363,24 @@ suite('GetEnvironmentInfoTool Tests', () => { test('should return successful with weird environment info', async () => { // create mock of PythonEnvironment const mockEnvironmentSuccess = typeMoq.Mock.ofType(); - // mockEnvironment = typeMoq.Mock.ofType(); - // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // Create an instance of GetEnvironmentInfoTool with the mock functions + let managerSys = typeMoq.Mock.ofType(); + managerSys.setup((m) => m.id).returns(() => 'ms-python.python:system'); + managerSys.setup((m) => m.name).returns(() => 'system'); + managerSys.setup((m) => m.displayName).returns(() => 'Test Manager'); + + let emSys = typeMoq.Mock.ofType(); + emSys.setup((e) => e.managers).returns(() => [managerSys.object]); + emSys.setup((e) => e.getEnvironmentManager(typeMoq.It.isAnyString())).returns(() => managerSys.object); + getEnvironmentInfoTool = new GetEnvironmentInfoTool(mockApi.object, emSys.object); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any mockEnvironmentSuccess.setup((x: any) => x.then).returns(() => undefined); mockEnvironmentSuccess.setup((x) => x.version).returns(() => '3.12.1'); const mockEnvId = typeMoq.Mock.ofType(); mockEnvId.setup((x) => x.managerId).returns(() => 'ms-python.python:system'); - manager.setup((m) => m.name).returns(() => 'system'); + managerSys.setup((m) => m.name).returns(() => 'system'); mockEnvironmentSuccess.setup((x) => x.envId).returns(() => mockEnvId.object); mockEnvironmentSuccess .setup((x) => x.execInfo) @@ -429,10 +414,9 @@ suite('GetEnvironmentInfoTool Tests', () => { // assert const content = result.content as vscode.LanguageModelTextPart[]; const firstPart = content[0] as vscode.MarkdownString; - console.log('result', firstPart.value); assert.strictEqual(firstPart.value.includes('3.12.1'), true); assert.strictEqual(firstPart.value.includes('"packages": []'), true); assert.strictEqual(firstPart.value.includes(`"path/to/venv/bin/python"`), true); - assert.strictEqual(firstPart.value.includes('sys'), true); + assert.strictEqual(firstPart.value.includes('system'), true); }); }); From d68c55ea8a07b230473875cbd2ef2c704886d139 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:32:20 -0700 Subject: [PATCH 10/10] fix to uri parsing --- src/features/copilotTools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/copilotTools.ts b/src/features/copilotTools.ts index 6a5adad1..143858fa 100644 --- a/src/features/copilotTools.ts +++ b/src/features/copilotTools.ts @@ -59,7 +59,7 @@ export class GetEnvironmentInfoTool implements LanguageModelTool