Conversation
There was a problem hiding this comment.
Pull request overview
This pull request implements Python installation via uv when no Python environments are found, addressing issue #1172. The feature prompts users to install Python using the uv tool, which can install Python without requiring Python to be present first. This streamlines the experience for new Python users or those setting up fresh machines.
Changes:
- Added Python installation functionality through uv with cross-platform support (Windows, macOS, Linux)
- Integrated installation prompts at two trigger points: extension activation and environment creation
- Added persistent "Don't ask again" state management for user preferences
- Implemented comprehensive telemetry tracking for installation flow
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/managers/builtin/uvPythonInstaller.ts | New module implementing uv and Python installation logic with platform-specific commands, task execution, and user prompts |
| src/test/managers/builtin/uvPythonInstaller.unit.test.ts | Unit tests for prompt behavior, persistent state management, and telemetry |
| src/managers/builtin/sysPythonManager.ts | Added prompt during initialization and create method to install Python globally via uv |
| src/managers/builtin/venvManager.ts | Added prompt during venv creation when no global Python environments exist |
| src/features/views/treeViewItems.ts | Updated UI labels for system manager to show "click to install" when no Python found |
| src/common/telemetry/constants.ts | Added four telemetry events for tracking installation flow |
| src/common/localize.ts | Added localized strings for installation UI and messages |
Comments suppressed due to low confidence (5)
src/managers/builtin/uvPythonInstaller.ts:253
- Direct usage of
window.showErrorMessageandwindow.showInformationMessagefrom the vscode module should be replaced with the wrapper functions fromsrc/common/window.apis.ts. While one wrapper is already imported and used for the prompt (line 189), these direct calls should be replaced with the importedshowErrorMessagefrom window.apis for consistency.
window.showErrorMessage(UvInstallStrings.uvInstallFailed);
return false;
}
}
// Step 2: Install Python via uv
const pythonSuccess = await installPythonViaUv(log);
if (!pythonSuccess) {
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' });
window.showErrorMessage(UvInstallStrings.installFailed);
return false;
}
// Step 3: Refresh environments to detect newly installed Python
traceInfo('Refreshing environments after Python installation...');
await api.refreshEnvironments(undefined);
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_COMPLETED);
window.showInformationMessage(UvInstallStrings.installComplete);
src/managers/builtin/uvPythonInstaller.ts:36
- The
isCommandAvailablefunction doesn't handle the case where the spawned process might hang indefinitely. If the--versioncommand forcurlorwgetdoesn't respond, the Promise will never resolve, causinggetUvInstallCommandto hang. Consider adding a timeout mechanism usingsetTimeoutto reject the promise after a reasonable duration (e.g., 5 seconds).
async function isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawnProcess(command, ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
}
src/managers/builtin/uvPythonInstaller.ts:95
- Direct usage of
tasks.onDidEndTaskProcessfrom the vscode module should ideally use a wrapper function for consistency with the codebase pattern. However, no wrapper exists yet insrc/common/tasks.apis.ts. Consider adding a wrapper function there and using it here, similar to howexecuteTaskis wrapped.
const disposable = tasks.onDidEndTaskProcess((e) => {
src/managers/builtin/sysPythonManager.ts:242
- The comment states "The installPythonWithUv function already refreshes environments", but there's a potential timing issue. The
installPythonWithUvfunction callsapi.refreshEnvironments(undefined)which likely triggerssysPythonManager.refresh(), updatingsysPythonManager.collection. However, thecreatemethod immediately reads fromthis.collectionwhich might not yet reflect the newly installed Python if the refresh is still in progress or hasn't propagated. Consider awaiting a small delay or re-fetching environments explicitly within thecreatemethod after theinstallPythonWithUvcall to ensure the collection is up-to-date.
const success = await installPythonWithUv(this.api, this.log);
if (success) {
// Return the latest Python environment after installation
// The installPythonWithUv function already refreshes environments
return getLatest(this.collection);
src/test/managers/builtin/uvPythonInstaller.unit.test.ts:123
- Missing test case for when user clicks "Install Python" button. The tests cover the "Don't ask again" and dismiss scenarios, but there's no test verifying that when the user selects
UvInstallStrings.installPython, the function callsinstallPythonWithUvand returns its result. This is a critical flow that should be tested to ensure proper integration.
suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
let mockLog: LogOutputChannel;
let mockApi: { refreshEnvironments: sinon.SinonStub };
let isUvInstalledStub: sinon.SinonStub;
let showInformationMessageStub: sinon.SinonStub;
let sendTelemetryEventStub: sinon.SinonStub;
let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub };
setup(() => {
mockLog = createMockLogOutputChannel();
mockApi = { refreshEnvironments: sinon.stub().resolves() };
mockState = {
get: sinon.stub(),
set: sinon.stub().resolves(),
clear: sinon.stub().resolves(),
};
sinon.stub(persistentState, 'getGlobalPersistentState').resolves(mockState);
isUvInstalledStub = sinon.stub(helpers, 'isUvInstalled');
showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage');
sendTelemetryEventStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
});
teardown(() => {
sinon.restore();
});
test('should return false when "Don\'t ask again" is set', async () => {
mockState.get.resolves(true);
const result = await promptInstallPythonViaUv('activation', mockApi as any, mockLog);
assert.strictEqual(result, false);
assert(showInformationMessageStub.notCalled, 'Should not show message when dont ask again is set');
assert(sendTelemetryEventStub.notCalled, 'Should not send telemetry when skipping prompt');
});
test('should show correct prompt when uv is installed', async () => {
mockState.get.resolves(false);
isUvInstalledStub.resolves(true);
showInformationMessageStub.resolves(undefined); // User dismissed
await promptInstallPythonViaUv('activation', mockApi as any, mockLog);
assert(
showInformationMessageStub.calledWith(
UvInstallStrings.installPythonPrompt,
UvInstallStrings.installPython,
UvInstallStrings.dontAskAgain,
),
'Should show install Python prompt when uv is installed',
);
});
test('should show correct prompt when uv is NOT installed', async () => {
mockState.get.resolves(false);
isUvInstalledStub.resolves(false);
showInformationMessageStub.resolves(undefined); // User dismissed
await promptInstallPythonViaUv('activation', mockApi as any, mockLog);
assert(
showInformationMessageStub.calledWith(
UvInstallStrings.installPythonAndUvPrompt,
UvInstallStrings.installPython,
UvInstallStrings.dontAskAgain,
),
'Should show install Python AND uv prompt when uv is not installed',
);
});
test('should set persistent state when user clicks "Don\'t ask again"', async () => {
mockState.get.resolves(false);
isUvInstalledStub.resolves(true);
showInformationMessageStub.resolves(UvInstallStrings.dontAskAgain);
const result = await promptInstallPythonViaUv('activation', mockApi as any, mockLog);
assert.strictEqual(result, false);
assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', true), 'Should set dont ask flag');
});
test('should return false when user dismisses the dialog', async () => {
mockState.get.resolves(false);
isUvInstalledStub.resolves(true);
showInformationMessageStub.resolves(undefined); // User dismissed
const result = await promptInstallPythonViaUv('activation', mockApi as any, mockLog);
assert.strictEqual(result, false);
});
test('should send telemetry with correct trigger', async () => {
mockState.get.resolves(false);
isUvInstalledStub.resolves(true);
showInformationMessageStub.resolves(undefined);
await promptInstallPythonViaUv('createEnvironment', mockApi as any, mockLog);
assert(
sendTelemetryEventStub.calledWith(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, {
trigger: 'createEnvironment',
}),
'Should send telemetry with createEnvironment trigger',
);
});
});
| // Return a simple unique ID based on command | ||
| const cmd = typeof this._command === 'string' ? this._command : (this._command?.value ?? ''); | ||
| return `shell-${cmd}-${Date.now()}`; |
There was a problem hiding this comment.
ShellExecution.computeId() now uses Date.now(), which makes IDs non-deterministic and can introduce flaky tests / inconsistent behavior when the same command is executed multiple times. Consider generating a stable ID (e.g., hash of command + args) or using a monotonic counter scoped to the mock instead.
| const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); | ||
|
|
||
| assert.strictEqual(result, false); | ||
| assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', true), 'Should set dont ask flag'); |
There was a problem hiding this comment.
These assertions hardcode the persistent-state key string. To avoid brittle tests if the key changes, consider exporting the key constant from uvPythonInstaller.ts (or exposing a small getter) and reusing it in tests.
| assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', true), 'Should set dont ask flag'); | |
| assert( | |
| mockState.set.calledWith(sinon.match.any, true), | |
| 'Should set dont ask flag', | |
| ); |
| export const noEnvFound = l10n.t('No python environments found.'); | ||
| export const createEnvironment = l10n.t('Create Environment'); |
There was a problem hiding this comment.
User-facing string uses "python" in lowercase. For consistency with other UI strings and product naming, this should be capitalized to "Python".
| // Step 1: Install uv if not present | ||
| if (!uvInstalled) { | ||
| traceInfo('uv not found, installing uv first...'); | ||
|
|
||
| const uvSuccess = await installUv(log); | ||
| if (!uvSuccess) { | ||
| sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvInstall' }); | ||
| showErrorMessage(UvInstallStrings.uvInstallFailed); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // Step 2: Install Python via uv | ||
| const pythonSuccess = await installPythonViaUv(log); | ||
| if (!pythonSuccess) { |
There was a problem hiding this comment.
After successfully installing uv, the next step immediately runs uv python install. Depending on how uv is installed, the new uv executable may not be on PATH for subsequent tasks/shells (install script often updates shell profile). Consider re-checking isUvInstalled() (or resolving uv’s install location) after installUv() and showing an actionable error (e.g., restart VS Code / open a new terminal) if uv still isn’t discoverable, to avoid a confusing failure at the Python-install stage.
Fixes #1172
Introduce functionality to prompt users for Python installation using uv when no environments are found. This includes updates to the UI and event tracking for the installation process.