Skip to content

Commit 01210fd

Browse files
Copiloteleanorjboyd
andcommitted
Implement enhanced pytest installation flow and improved error messages
Co-authored-by: eleanorjboyd <[email protected]>
1 parent bfb5b66 commit 01210fd

File tree

5 files changed

+297
-3
lines changed

5 files changed

+297
-3
lines changed

src/client/testing/configuration/pytest/testConfigurationManager.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import { QuickPickItem, Uri } from 'vscode';
33
import { IFileSystem } from '../../../common/platform/types';
44
import { Product } from '../../../common/types';
55
import { IServiceContainer } from '../../../ioc/types';
6+
import { IApplicationShell } from '../../../common/application/types';
67
import { TestConfigurationManager } from '../../common/testConfigurationManager';
78
import { ITestConfigSettingsService } from '../../common/types';
9+
import { PytestInstallationHelper } from '../pytestInstallationHelper';
10+
import { traceInfo } from '../../../logging';
811

912
export class ConfigurationManager extends TestConfigurationManager {
13+
private readonly pytestInstallationHelper: PytestInstallationHelper;
14+
1015
constructor(workspace: Uri, serviceContainer: IServiceContainer, cfg?: ITestConfigSettingsService) {
1116
super(workspace, Product.pytest, serviceContainer, cfg);
17+
const appShell = serviceContainer.get<IApplicationShell>(IApplicationShell);
18+
this.pytestInstallationHelper = new PytestInstallationHelper(appShell);
1219
}
1320

1421
public async requiresUserToConfigure(wkspace: Uri): Promise<boolean> {
@@ -43,7 +50,19 @@ export class ConfigurationManager extends TestConfigurationManager {
4350
}
4451
const installed = await this.installer.isInstalled(Product.pytest);
4552
if (!installed) {
46-
await this.installer.install(Product.pytest);
53+
// Check if Python Environments extension is available for enhanced installation flow
54+
if (this.pytestInstallationHelper.isEnvExtensionAvailable()) {
55+
traceInfo('pytest not installed, prompting user with environment extension integration');
56+
const installAttempted = await this.pytestInstallationHelper.promptToInstallPytest(wkspace);
57+
if (!installAttempted) {
58+
// User chose to ignore or installation failed
59+
return;
60+
}
61+
} else {
62+
// Fall back to traditional installer
63+
traceInfo('pytest not installed, falling back to traditional installer');
64+
await this.installer.install(Product.pytest);
65+
}
4766
}
4867
await this.testConfigSettingsService.updateTestArgs(wkspace.fsPath, Product.pytest, args);
4968
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { Uri, l10n } from 'vscode';
5+
import { IApplicationShell } from '../../common/application/types';
6+
import { traceInfo, traceError } from '../../logging';
7+
import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal';
8+
import { getEnvironment } from '../../envExt/api.internal';
9+
10+
/**
11+
* Helper class to handle pytest installation using the appropriate method
12+
* based on whether the Python Environments extension is available.
13+
*/
14+
export class PytestInstallationHelper {
15+
constructor(private readonly appShell: IApplicationShell) {}
16+
17+
/**
18+
* Prompts the user to install pytest with appropriate installation method.
19+
* @param workspaceUri The workspace URI where pytest should be installed
20+
* @returns Promise that resolves to true if installation was attempted, false otherwise
21+
*/
22+
async promptToInstallPytest(workspaceUri: Uri): Promise<boolean> {
23+
const message = l10n.t('pytest selected but not installed. Would you like to install pytest?');
24+
const installOption = l10n.t('Install pytest');
25+
const ignoreOption = l10n.t('Ignore');
26+
27+
const selection = await this.appShell.showInformationMessage(message, installOption, ignoreOption);
28+
29+
if (selection === installOption) {
30+
return this.installPytest(workspaceUri);
31+
}
32+
33+
return false;
34+
}
35+
36+
/**
37+
* Installs pytest using the appropriate method based on available extensions.
38+
* @param workspaceUri The workspace URI where pytest should be installed
39+
* @returns Promise that resolves to true if installation was successful, false otherwise
40+
*/
41+
private async installPytest(workspaceUri: Uri): Promise<boolean> {
42+
try {
43+
if (useEnvExtension()) {
44+
return this.installPytestWithEnvExtension(workspaceUri);
45+
} else {
46+
// Fall back to traditional installer if environments extension is not available
47+
traceInfo('Python Environments extension not available, installation cannot proceed via environment extension');
48+
return false;
49+
}
50+
} catch (error) {
51+
traceError('Error installing pytest:', error);
52+
return false;
53+
}
54+
}
55+
56+
/**
57+
* Installs pytest using the Python Environments extension.
58+
* @param workspaceUri The workspace URI where pytest should be installed
59+
* @returns Promise that resolves to true if installation was successful, false otherwise
60+
*/
61+
private async installPytestWithEnvExtension(workspaceUri: Uri): Promise<boolean> {
62+
try {
63+
const envExtApi = await getEnvExtApi();
64+
const environment = await getEnvironment(workspaceUri);
65+
66+
if (!environment) {
67+
traceError('No Python environment found for workspace:', workspaceUri.fsPath);
68+
await this.appShell.showErrorMessage(
69+
l10n.t('No Python environment found. Please set up a Python environment first.')
70+
);
71+
return false;
72+
}
73+
74+
traceInfo('Installing pytest using Python Environments extension...');
75+
await envExtApi.managePackages(environment, {
76+
install: ['pytest'],
77+
});
78+
79+
traceInfo('pytest installation completed successfully');
80+
await this.appShell.showInformationMessage(l10n.t('pytest has been installed successfully.'));
81+
return true;
82+
} catch (error) {
83+
traceError('Failed to install pytest using Python Environments extension:', error);
84+
await this.appShell.showErrorMessage(
85+
l10n.t('Failed to install pytest. Please install it manually or check your Python environment.')
86+
);
87+
return false;
88+
}
89+
}
90+
91+
/**
92+
* Checks if the Python Environments extension is available for package management.
93+
* @returns True if the extension is available, false otherwise
94+
*/
95+
isEnvExtensionAvailable(): boolean {
96+
return useEnvExtension();
97+
}
98+
}

src/client/testing/testController/common/utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,31 @@ export async function startDiscoveryNamedPipe(
174174
return pipeName;
175175
}
176176

177+
/**
178+
* Detects if an error message indicates that pytest is not installed.
179+
* @param message The error message to check
180+
* @returns True if the error indicates pytest is not installed
181+
*/
182+
function isPytestNotInstalledError(message: string): boolean {
183+
return message.includes('ModuleNotFoundError') && message.includes('pytest') ||
184+
message.includes('No module named') && message.includes('pytest') ||
185+
message.includes('ImportError') && message.includes('pytest');
186+
}
187+
177188
export function buildErrorNodeOptions(uri: Uri, message: string, testType: string): ErrorTestItemOptions {
178-
const labelText = testType === 'pytest' ? 'pytest Discovery Error' : 'Unittest Discovery Error';
189+
let labelText = testType === 'pytest' ? 'pytest Discovery Error' : 'Unittest Discovery Error';
190+
let errorMessage = message;
191+
192+
// Provide more specific error message if pytest is not installed
193+
if (testType === 'pytest' && isPytestNotInstalledError(message)) {
194+
labelText = 'pytest Not Installed';
195+
errorMessage = 'pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.';
196+
}
197+
179198
return {
180199
id: `DiscoveryError:${uri.fsPath}`,
181200
label: `${labelText} [${path.basename(uri.fsPath)}]`,
182-
error: message,
201+
error: errorMessage,
183202
};
184203
}
185204

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { expect } from 'chai';
5+
import * as sinon from 'sinon';
6+
import { Uri } from 'vscode';
7+
import * as TypeMoq from 'typemoq';
8+
import { IApplicationShell } from '../../../client/common/application/types';
9+
import { PytestInstallationHelper } from '../../../client/testing/configuration/pytestInstallationHelper';
10+
import * as envExtApi from '../../../client/envExt/api.internal';
11+
12+
suite('PytestInstallationHelper', () => {
13+
let appShell: TypeMoq.IMock<IApplicationShell>;
14+
let helper: PytestInstallationHelper;
15+
let useEnvExtensionStub: sinon.SinonStub;
16+
let getEnvExtApiStub: sinon.SinonStub;
17+
let getEnvironmentStub: sinon.SinonStub;
18+
19+
const workspaceUri = Uri.file('/test/workspace');
20+
21+
setup(() => {
22+
appShell = TypeMoq.Mock.ofType<IApplicationShell>();
23+
helper = new PytestInstallationHelper(appShell.object);
24+
25+
useEnvExtensionStub = sinon.stub(envExtApi, 'useEnvExtension');
26+
getEnvExtApiStub = sinon.stub(envExtApi, 'getEnvExtApi');
27+
getEnvironmentStub = sinon.stub(envExtApi, 'getEnvironment');
28+
});
29+
30+
teardown(() => {
31+
sinon.restore();
32+
});
33+
34+
test('promptToInstallPytest should return false if user selects ignore', async () => {
35+
appShell
36+
.setup((a) => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()))
37+
.returns(() => Promise.resolve('Ignore'))
38+
.verifiable(TypeMoq.Times.once());
39+
40+
const result = await helper.promptToInstallPytest(workspaceUri);
41+
42+
expect(result).to.be.false;
43+
appShell.verifyAll();
44+
});
45+
46+
test('promptToInstallPytest should return false if user cancels', async () => {
47+
appShell
48+
.setup((a) => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()))
49+
.returns(() => Promise.resolve(undefined))
50+
.verifiable(TypeMoq.Times.once());
51+
52+
const result = await helper.promptToInstallPytest(workspaceUri);
53+
54+
expect(result).to.be.false;
55+
appShell.verifyAll();
56+
});
57+
58+
test('isEnvExtensionAvailable should return result from useEnvExtension', () => {
59+
useEnvExtensionStub.returns(true);
60+
61+
const result = helper.isEnvExtensionAvailable();
62+
63+
expect(result).to.be.true;
64+
expect(useEnvExtensionStub.calledOnce).to.be.true;
65+
});
66+
67+
test('promptToInstallPytest should return false if env extension not available', async () => {
68+
useEnvExtensionStub.returns(false);
69+
70+
appShell
71+
.setup((a) => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny()))
72+
.returns(() => Promise.resolve('Install pytest'))
73+
.verifiable(TypeMoq.Times.once());
74+
75+
const result = await helper.promptToInstallPytest(workspaceUri);
76+
77+
expect(result).to.be.false;
78+
appShell.verifyAll();
79+
});
80+
81+
test('promptToInstallPytest should attempt installation when env extension is available', async () => {
82+
useEnvExtensionStub.returns(true);
83+
84+
const mockEnvironment = { envId: { id: 'test-env', managerId: 'test-manager' } };
85+
const mockEnvExtApi = {
86+
managePackages: sinon.stub().resolves()
87+
};
88+
89+
getEnvExtApiStub.resolves(mockEnvExtApi);
90+
getEnvironmentStub.resolves(mockEnvironment);
91+
92+
appShell
93+
.setup((a) => a.showInformationMessage(
94+
TypeMoq.It.is((msg: string) => msg.includes('pytest selected but not installed')),
95+
TypeMoq.It.isAny(),
96+
TypeMoq.It.isAny()
97+
))
98+
.returns(() => Promise.resolve('Install pytest'))
99+
.verifiable(TypeMoq.Times.once());
100+
101+
appShell
102+
.setup((a) => a.showInformationMessage(TypeMoq.It.is((msg: string) => msg.includes('successfully'))))
103+
.returns(() => Promise.resolve(undefined))
104+
.verifiable(TypeMoq.Times.once());
105+
106+
const result = await helper.promptToInstallPytest(workspaceUri);
107+
108+
expect(result).to.be.true;
109+
expect(mockEnvExtApi.managePackages.calledOnceWithExactly(mockEnvironment, { install: ['pytest'] })).to.be.true;
110+
appShell.verifyAll();
111+
});
112+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { expect } from 'chai';
5+
import { Uri } from 'vscode';
6+
import { buildErrorNodeOptions } from '../../../../client/testing/testController/common/utils';
7+
8+
suite('buildErrorNodeOptions - pytest not installed detection', () => {
9+
const workspaceUri = Uri.file('/test/workspace');
10+
11+
test('Should detect pytest ModuleNotFoundError and provide specific message', () => {
12+
const errorMessage = 'Traceback (most recent call last):\n File "<string>", line 1, in <module>\n import pytest\nModuleNotFoundError: No module named \'pytest\'';
13+
14+
const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest');
15+
16+
expect(result.label).to.equal('pytest Not Installed [workspace]');
17+
expect(result.error).to.equal('pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.');
18+
});
19+
20+
test('Should detect pytest ImportError and provide specific message', () => {
21+
const errorMessage = 'ImportError: No module named pytest';
22+
23+
const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest');
24+
25+
expect(result.label).to.equal('pytest Not Installed [workspace]');
26+
expect(result.error).to.equal('pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.');
27+
});
28+
29+
test('Should use generic error for non-pytest-related errors', () => {
30+
const errorMessage = 'Some other error occurred';
31+
32+
const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest');
33+
34+
expect(result.label).to.equal('pytest Discovery Error [workspace]');
35+
expect(result.error).to.equal('Some other error occurred');
36+
});
37+
38+
test('Should use generic error for unittest errors', () => {
39+
const errorMessage = 'ModuleNotFoundError: No module named \'pytest\'';
40+
41+
const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'unittest');
42+
43+
expect(result.label).to.equal('Unittest Discovery Error [workspace]');
44+
expect(result.error).to.equal('ModuleNotFoundError: No module named \'pytest\'');
45+
});
46+
});

0 commit comments

Comments
 (0)