From caa182ea34aac98e89c2cc44eb4ec11928c4afb5 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 5 Feb 2026 17:45:28 -0800 Subject: [PATCH 01/13] feat: implement Python installation via uv --- src/common/localize.ts | 20 ++ src/common/telemetry/constants.ts | 43 +++- src/features/views/treeViewItems.ts | 20 +- src/managers/builtin/sysPythonManager.ts | 27 +++ src/managers/builtin/uvPythonInstaller.ts | 274 ++++++++++++++++++++++ src/managers/builtin/venvManager.ts | 15 +- 6 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 src/managers/builtin/uvPythonInstaller.ts diff --git a/src/common/localize.ts b/src/common/localize.ts index 7191a184..e159be93 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -96,6 +96,10 @@ export namespace VenvManagerStrings { export const venvErrorNoBasePython = l10n.t('No base Python found'); export const venvErrorNoPython3 = l10n.t('Did not find any base Python 3'); + export const noEnvClickToCreate = l10n.t('No environment found, click to create'); + export const noEnvFound = l10n.t('No python environments found.'); + export const createEnvironment = l10n.t('Create Environment'); + export const venvName = l10n.t('Enter a name for the virtual environment'); export const venvNameErrorEmpty = l10n.t('Name cannot be empty'); export const venvNameErrorExists = l10n.t('A folder with the same name already exists'); @@ -206,3 +210,19 @@ export namespace ActivationStrings { ); export const activatingEnvironment = l10n.t('Activating environment'); } + +export namespace UvInstallStrings { + export const noPythonFound = l10n.t('No Python installation found'); + export const installPythonPrompt = l10n.t('No Python found. Would you like to install Python using uv?'); + export const installPythonAndUvPrompt = l10n.t( + 'No Python found. Would you like to install uv and use it to install Python?', + ); + export const installPython = l10n.t('Install Python'); + export const installingUv = l10n.t('Installing uv...'); + export const installingPython = l10n.t('Installing Python via uv...'); + export const installComplete = l10n.t('Python installed successfully'); + export const installFailed = l10n.t('Failed to install Python'); + export const uvInstallFailed = l10n.t('Failed to install uv'); + export const dontAskAgain = l10n.t("Don't ask again"); + export const clickToInstallPython = l10n.t('No Python found, click to install'); +} diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index efd58ca6..d9120b6e 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -10,6 +10,11 @@ export enum EventNames { VENV_USING_UV = 'VENV.USING_UV', VENV_CREATION = 'VENV.CREATION', + UV_PYTHON_INSTALL_PROMPTED = 'UV.PYTHON_INSTALL_PROMPTED', + UV_PYTHON_INSTALL_STARTED = 'UV.PYTHON_INSTALL_STARTED', + UV_PYTHON_INSTALL_COMPLETED = 'UV.PYTHON_INSTALL_COMPLETED', + UV_PYTHON_INSTALL_FAILED = 'UV.PYTHON_INSTALL_FAILED', + PACKAGE_MANAGEMENT = 'PACKAGE_MANAGEMENT', ADD_PROJECT = 'ADD_PROJECT', /** @@ -83,15 +88,49 @@ export interface IEventNamePropertyMapping { /* __GDPR__ "venv.using_uv": {"owner": "eleanorjboyd" } */ - [EventNames.VENV_USING_UV]: never | undefined /* __GDPR__ + [EventNames.VENV_USING_UV]: never | undefined; + + /* __GDPR__ "venv.creation": { "creationType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" } } - */; + */ [EventNames.VENV_CREATION]: { creationType: 'quick' | 'custom'; }; + /* __GDPR__ + "uv.python_install_prompted": { + "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karthiknadig" } + } + */ + [EventNames.UV_PYTHON_INSTALL_PROMPTED]: { + trigger: 'activation' | 'createEnvironment'; + }; + + /* __GDPR__ + "uv.python_install_started": { + "uvAlreadyInstalled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karthiknadig" } + } + */ + [EventNames.UV_PYTHON_INSTALL_STARTED]: { + uvAlreadyInstalled: boolean; + }; + + /* __GDPR__ + "uv.python_install_completed": {"owner": "karthiknadig" } + */ + [EventNames.UV_PYTHON_INSTALL_COMPLETED]: never | undefined; + + /* __GDPR__ + "uv.python_install_failed": { + "stage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karthiknadig" } + } + */ + [EventNames.UV_PYTHON_INSTALL_FAILED]: { + stage: 'uvInstall' | 'pythonInstall'; + }; + /* __GDPR__ "package_management": { "managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }, diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index ac7e5b1b..6c36969a 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -1,6 +1,6 @@ import { Command, MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { EnvironmentGroupInfo, IconPath, Package, PythonEnvironment, PythonProject } from '../../api'; -import { EnvViewStrings } from '../../common/localize'; +import { EnvViewStrings, UvInstallStrings, VenvManagerStrings } from '../../common/localize'; import { InternalEnvironmentManager, InternalPackageManager } from '../../internal.api'; import { isActivatableEnvironment } from '../common/activation'; import { removable } from './utils'; @@ -115,12 +115,16 @@ export class NoPythonEnvTreeItem implements EnvTreeItem { private readonly tooltip?: string | MarkdownString, private readonly iconPath?: string | IconPath, ) { - const item = new TreeItem( - this.parent.manager.supportsCreate - ? 'No environment found, click to create' - : 'No python environments found.', - TreeItemCollapsibleState.None, - ); + // Use special message for system manager (Python installation) + const isSystemManager = this.parent.manager.name === 'system'; + let label: string; + if (this.parent.manager.supportsCreate) { + label = isSystemManager ? UvInstallStrings.clickToInstallPython : VenvManagerStrings.noEnvClickToCreate; + } else { + label = VenvManagerStrings.noEnvFound; + } + + const item = new TreeItem(label, TreeItemCollapsibleState.None); item.contextValue = 'python-no-environment'; item.description = this.description; item.tooltip = this.tooltip; @@ -128,7 +132,7 @@ export class NoPythonEnvTreeItem implements EnvTreeItem { if (this.parent.manager.supportsCreate) { item.command = { command: 'python-envs.create', - title: 'Create Environment', + title: isSystemManager ? UvInstallStrings.installPython : VenvManagerStrings.createEnvironment, arguments: [this.parent], }; } diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 505d940a..48ef93ec 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -1,6 +1,8 @@ import * as path from 'path'; import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri, window } from 'vscode'; import { + CreateEnvironmentOptions, + CreateEnvironmentScope, DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, EnvironmentChangeKind, @@ -28,6 +30,7 @@ import { setSystemEnvForWorkspaces, } from './cache'; import { refreshPythons, resolveSystemPythonEnvironmentPath } from './utils'; +import { installPythonWithUv, promptInstallPythonViaUv } from './uvPythonInstaller'; export class SysPythonManager implements EnvironmentManager { private collection: PythonEnvironment[] = []; @@ -70,6 +73,11 @@ export class SysPythonManager implements EnvironmentManager { await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering); + // If no Python environments were found, offer to install via uv + if (this.collection.length === 0) { + promptInstallPythonViaUv('activation', this.api, this.log); + } + this._initialized.resolve(); } @@ -218,6 +226,25 @@ export class SysPythonManager implements EnvironmentManager { return resolved; } + /** + * Installs a global Python using uv. + * This method installs uv if not present, then uses it to install Python. + */ + async create( + _scope: CreateEnvironmentScope, + _options?: CreateEnvironmentOptions, + ): Promise { + 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); + } + + return undefined; + } + async clearCache(): Promise { await clearSystemEnvCache(); } diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts new file mode 100644 index 00000000..0688c9b4 --- /dev/null +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -0,0 +1,274 @@ +import { + LogOutputChannel, + ProgressLocation, + ShellExecution, + Task, + TaskPanelKind, + TaskRevealKind, + TaskScope, + tasks, + window, +} from 'vscode'; +import { PythonEnvironmentApi } from '../../api'; +import { spawnProcess } from '../../common/childProcess.apis'; +import { UvInstallStrings } from '../../common/localize'; +import { traceError, traceInfo, traceLog } from '../../common/logging'; +import { getGlobalPersistentState } from '../../common/persistentState'; +import { executeTask } from '../../common/tasks.apis'; +import { EventNames } from '../../common/telemetry/constants'; +import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { createDeferred } from '../../common/utils/deferred'; +import { isWindows } from '../../common/utils/platformUtils'; +import { showInformationMessage } from '../../common/window.apis'; +import { isUvInstalled, resetUvInstallationCache } from './helpers'; + +const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; + +/** + * Checks if a command is available on the system. + */ +async function isCommandAvailable(command: string): Promise { + return new Promise((resolve) => { + const proc = spawnProcess(command, ['--version']); + proc.on('error', () => resolve(false)); + proc.on('exit', (code) => resolve(code === 0)); + }); +} + +/** + * Returns the platform-specific command to install uv. + * On Unix, prefers curl but falls back to wget if curl is not available. + */ +async function getUvInstallCommand(): Promise<{ executable: string; args: string[] }> { + if (isWindows()) { + return { + executable: 'powershell', + args: ['-ExecutionPolicy', 'Bypass', '-c', 'irm https://astral.sh/uv/install.ps1 | iex'], + }; + } + + // macOS and Linux: try curl first, then wget + if (await isCommandAvailable('curl')) { + traceInfo('Using curl to install uv'); + return { + executable: 'sh', + args: ['-c', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], + }; + } + + if (await isCommandAvailable('wget')) { + traceInfo('curl not found, using wget to install uv'); + return { + executable: 'sh', + args: ['-c', 'wget -qO- https://astral.sh/uv/install.sh | sh'], + }; + } + + // Default to curl and let it fail with a clear error if neither is available + traceError('Neither curl nor wget found, attempting curl anyway'); + return { + executable: 'sh', + args: ['-c', 'curl -LsSf https://astral.sh/uv/install.sh | sh'], + }; +} + +/** + * Runs a shell command as a visible VS Code task and waits for completion. + * @param name Task name displayed in the UI + * @param executable The command to run + * @param args Arguments for the command + * @returns Promise that resolves to true if the task completed successfully + */ +async function runTaskAndWait(name: string, executable: string, args: string[]): Promise { + const task = new Task({ type: 'shell' }, TaskScope.Global, name, 'Python', new ShellExecution(executable, args)); + + task.presentationOptions = { + reveal: TaskRevealKind.Always, + echo: true, + panel: TaskPanelKind.Shared, + close: false, + showReuseMessage: false, + }; + + const deferred = createDeferred(); + + const disposable = tasks.onDidEndTaskProcess((e) => { + if (e.execution.task === task) { + disposable.dispose(); + deferred.resolve(e.exitCode === 0); + } + }); + + try { + await executeTask(task); + return await deferred.promise; + } catch (err) { + disposable.dispose(); + traceError(`Task "${name}" failed:`, err); + return false; + } +} + +/** + * Installs uv using the platform-appropriate method. + * @param log Optional log output channel + * @returns Promise that resolves to true if uv was installed successfully + */ +export async function installUv(_log?: LogOutputChannel): Promise { + const { executable, args } = await getUvInstallCommand(); + traceInfo(`Installing uv: ${executable} ${args.join(' ')}`); + + const success = await runTaskAndWait(UvInstallStrings.installingUv, executable, args); + + if (success) { + // Reset the cache so isUvInstalled() will re-check + resetUvInstallationCache(); + traceInfo('uv installed successfully'); + } else { + traceError('Failed to install uv'); + } + + return success; +} + +/** + * Installs Python using uv. + * @param log Optional log output channel + * @param version Optional Python version to install (e.g., "3.12"). If not specified, installs the latest. + * @returns Promise that resolves to true if Python was installed successfully + */ +export async function installPythonViaUv(_log?: LogOutputChannel, version?: string): Promise { + const args = ['python', 'install']; + if (version) { + args.push(version); + } + + traceInfo(`Installing Python via uv: uv ${args.join(' ')}`); + + const success = await runTaskAndWait(UvInstallStrings.installingPython, 'uv', args); + + if (success) { + traceInfo('Python installed successfully via uv'); + } else { + traceError('Failed to install Python via uv'); + } + + return success; +} + +/** + * Prompts the user to install Python via uv when no Python is found. + * Respects the "Don't ask again" setting. + * + * @param trigger What triggered this prompt ('activation' or 'createEnvironment') + * @param api The Python environment API (used to refresh environments after installation) + * @param log Optional log output channel + * @returns Promise that resolves to true if Python was successfully installed + */ +export async function promptInstallPythonViaUv( + trigger: 'activation' | 'createEnvironment', + api: PythonEnvironmentApi, + log?: LogOutputChannel, +): Promise { + const state = await getGlobalPersistentState(); + const dontAsk = await state.get(UV_INSTALL_PYTHON_DONT_ASK_KEY); + + if (dontAsk) { + traceLog('Skipping Python install prompt: user selected "Don\'t ask again"'); + return false; + } + + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { trigger }); + + // Check if uv is installed to show appropriate message + const uvInstalled = await isUvInstalled(log); + const promptMessage = uvInstalled + ? UvInstallStrings.installPythonPrompt + : UvInstallStrings.installPythonAndUvPrompt; + + const result = await showInformationMessage( + promptMessage, + UvInstallStrings.installPython, + UvInstallStrings.dontAskAgain, + ); + + if (result === UvInstallStrings.dontAskAgain) { + await state.set(UV_INSTALL_PYTHON_DONT_ASK_KEY, true); + traceLog('User selected "Don\'t ask again" for Python install prompt'); + return false; + } + + if (result === UvInstallStrings.installPython) { + return await installPythonWithUv(api, log); + } + + return false; +} + +/** + * Installs Python using uv. If uv is not installed, installs it first. + * This is the main entry point for programmatic Python installation. + * + * @param api The Python environment API (used to refresh environments after installation) + * @param log Optional log output channel + * @returns Promise that resolves to true if Python was successfully installed + */ +export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOutputChannel): Promise { + const uvInstalled = await isUvInstalled(log); + + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled }); + + return await window.withProgress( + { + location: ProgressLocation.Notification, + title: UvInstallStrings.installingPython, + cancellable: false, + }, + async () => { + // 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' }); + 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); + + return true; + }, + ); +} + +/** + * Checks if the "Don't ask again" flag is set for Python installation prompts. + */ +export async function isDontAskAgainSet(): Promise { + const state = await getGlobalPersistentState(); + return (await state.get(UV_INSTALL_PYTHON_DONT_ASK_KEY)) ?? false; +} + +/** + * Clears the "Don't ask again" flag for Python installation prompts. + */ +export async function clearDontAskAgain(): Promise { + const state = await getGlobalPersistentState(); + await state.set(UV_INSTALL_PYTHON_DONT_ASK_KEY, false); +} diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 1fd2a84d..dd1d7c62 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -36,6 +36,7 @@ import { showErrorMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, shortVersion, sortEnvironments } from '../common/utils'; +import { promptInstallPythonViaUv } from './uvPythonInstaller'; import { clearVenvCache, CreateEnvironmentResult, @@ -142,7 +143,19 @@ export class VenvManager implements EnvironmentManager { const venvRoot: Uri = Uri.file(await findParentIfFile(uri.fsPath)); - const globals = await this.api.getEnvironments('global'); + let globals = await this.api.getEnvironments('global'); + + // If no Python environments found, offer to install Python via uv + if (globals.length === 0) { + const installed = await promptInstallPythonViaUv('createEnvironment', this.api, this.log); + if (installed) { + // Re-fetch environments after installation + globals = await this.api.getEnvironments('global'); + // Update globalEnv reference + this.globalEnv = getLatest(globals.filter((e) => e.version.startsWith('3.'))); + } + } + let result: CreateEnvironmentResult | undefined = undefined; if (options?.quickCreate) { // error on missing information From 0cc1ea68240ce745c61ff1f0a199053c8eaa14c7 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 5 Feb 2026 17:51:34 -0800 Subject: [PATCH 02/13] tests: add unit tests for uvPythonInstaller --- .../builtin/uvPythonInstaller.unit.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/test/managers/builtin/uvPythonInstaller.unit.test.ts diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts new file mode 100644 index 00000000..c1a99dd9 --- /dev/null +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -0,0 +1,170 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import { UvInstallStrings } from '../../../common/localize'; +import * as persistentState from '../../../common/persistentState'; +import { EventNames } from '../../../common/telemetry/constants'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import * as windowApis from '../../../common/window.apis'; +import * as helpers from '../../../managers/builtin/helpers'; +import { + clearDontAskAgain, + isDontAskAgainSet, + promptInstallPythonViaUv, +} from '../../../managers/builtin/uvPythonInstaller'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +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', + ); + }); +}); + +suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + + setup(() => { + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + sinon.stub(persistentState, 'getGlobalPersistentState').resolves(mockState); + }); + + teardown(() => { + sinon.restore(); + }); + + test('isDontAskAgainSet should return true when flag is set', async () => { + mockState.get.resolves(true); + + const result = await isDontAskAgainSet(); + + assert.strictEqual(result, true); + }); + + test('isDontAskAgainSet should return false when flag is not set', async () => { + mockState.get.resolves(false); + + const result = await isDontAskAgainSet(); + + assert.strictEqual(result, false); + }); + + test('isDontAskAgainSet should return false when flag is undefined', async () => { + mockState.get.resolves(undefined); + + const result = await isDontAskAgainSet(); + + assert.strictEqual(result, false); + }); + + test('clearDontAskAgain should set flag to false', async () => { + await clearDontAskAgain(); + + assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', false), 'Should clear the flag'); + }); +}); From b0270383a6c4942fbef848cf45d8554cbb3b1966 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 08:58:04 -0800 Subject: [PATCH 03/13] Address PR review comments: task listener cleanup, use wrappers, add await, handle empty envs --- src/common/tasks.apis.ts | 10 ++++- src/managers/builtin/sysPythonManager.ts | 2 +- src/managers/builtin/uvPythonInstaller.ts | 42 ++++++++++--------- src/managers/builtin/venvManager.ts | 9 +++- .../builtin/uvPythonInstaller.unit.test.ts | 5 +++ src/test/mocks/vsc/extHostedTypes.ts | 4 +- 6 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/common/tasks.apis.ts b/src/common/tasks.apis.ts index e8e70f1c..f2bd8d91 100644 --- a/src/common/tasks.apis.ts +++ b/src/common/tasks.apis.ts @@ -1,5 +1,13 @@ -import { Task, TaskExecution, tasks } from 'vscode'; +import { Disposable, Task, TaskExecution, TaskProcessEndEvent, tasks } from 'vscode'; export async function executeTask(task: Task): Promise { return tasks.executeTask(task); } + +export function onDidEndTaskProcess( + listener: (e: TaskProcessEndEvent) => unknown, + thisArgs?: unknown, + disposables?: Disposable[], +): Disposable { + return tasks.onDidEndTaskProcess(listener, thisArgs, disposables); +} diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 48ef93ec..b5c231ff 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -75,7 +75,7 @@ export class SysPythonManager implements EnvironmentManager { // If no Python environments were found, offer to install via uv if (this.collection.length === 0) { - promptInstallPythonViaUv('activation', this.api, this.log); + await promptInstallPythonViaUv('activation', this.api, this.log); } this._initialized.resolve(); diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 0688c9b4..eaaa3c7b 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -1,25 +1,15 @@ -import { - LogOutputChannel, - ProgressLocation, - ShellExecution, - Task, - TaskPanelKind, - TaskRevealKind, - TaskScope, - tasks, - window, -} from 'vscode'; +import { LogOutputChannel, ProgressLocation, ShellExecution, Task, TaskPanelKind, TaskRevealKind, TaskScope } from 'vscode'; import { PythonEnvironmentApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { UvInstallStrings } from '../../common/localize'; import { traceError, traceInfo, traceLog } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; -import { executeTask } from '../../common/tasks.apis'; +import { executeTask, onDidEndTaskProcess } from '../../common/tasks.apis'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred } from '../../common/utils/deferred'; import { isWindows } from '../../common/utils/platformUtils'; -import { showInformationMessage } from '../../common/window.apis'; +import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { isUvInstalled, resetUvInstallationCache } from './helpers'; const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; @@ -72,6 +62,9 @@ async function getUvInstallCommand(): Promise<{ executable: string; args: string }; } +// Timeout for task completion (5 minutes) +const TASK_TIMEOUT_MS = 5 * 60 * 1000; + /** * Runs a shell command as a visible VS Code task and waits for completion. * @param name Task name displayed in the UI @@ -92,20 +85,29 @@ async function runTaskAndWait(name: string, executable: string, args: string[]): const deferred = createDeferred(); - const disposable = tasks.onDidEndTaskProcess((e) => { + const disposable = onDidEndTaskProcess((e) => { if (e.execution.task === task) { - disposable.dispose(); deferred.resolve(e.exitCode === 0); } }); + // Set up timeout to prevent indefinite waiting + const timeoutId = setTimeout(() => { + if (!deferred.completed) { + traceError(`Task "${name}" timed out after ${TASK_TIMEOUT_MS / 1000} seconds`); + deferred.resolve(false); + } + }, TASK_TIMEOUT_MS); + try { await executeTask(task); return await deferred.promise; } catch (err) { - disposable.dispose(); traceError(`Task "${name}" failed:`, err); return false; + } finally { + clearTimeout(timeoutId); + disposable.dispose(); } } @@ -218,7 +220,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled }); - return await window.withProgress( + return await withProgress( { location: ProgressLocation.Notification, title: UvInstallStrings.installingPython, @@ -232,7 +234,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu const uvSuccess = await installUv(log); if (!uvSuccess) { sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvInstall' }); - window.showErrorMessage(UvInstallStrings.uvInstallFailed); + showErrorMessage(UvInstallStrings.uvInstallFailed); return false; } } @@ -241,7 +243,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu const pythonSuccess = await installPythonViaUv(log); if (!pythonSuccess) { sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' }); - window.showErrorMessage(UvInstallStrings.installFailed); + showErrorMessage(UvInstallStrings.installFailed); return false; } @@ -250,7 +252,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu await api.refreshEnvironments(undefined); sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_COMPLETED); - window.showInformationMessage(UvInstallStrings.installComplete); + showInformationMessage(UvInstallStrings.installComplete); return true; }, diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index dd1d7c62..2afe0f88 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -151,8 +151,13 @@ export class VenvManager implements EnvironmentManager { if (installed) { // Re-fetch environments after installation globals = await this.api.getEnvironments('global'); - // Update globalEnv reference - this.globalEnv = getLatest(globals.filter((e) => e.version.startsWith('3.'))); + // Update globalEnv reference if we found any Python 3.x environments + const python3Envs = globals.filter((e) => e.version.startsWith('3.')); + if (python3Envs.length === 0) { + this.log.warn('Python installed via uv but no Python 3.x global environments were detected.'); + } else { + this.globalEnv = getLatest(python3Envs); + } } } diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index c1a99dd9..fac7e0e0 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -168,3 +168,8 @@ suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', false), 'Should clear the flag'); }); }); + +// NOTE: Installation functions (installUv, installPythonViaUv, installPythonWithUv) require +// VS Code's Task API which cannot be fully mocked in unit tests. +// These should be tested via integration tests in a real VS Code environment. + diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index d80b45d0..2542895b 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1706,7 +1706,9 @@ export class ShellExecution implements vscode.ShellExecution { // } // } // return hash.digest('hex'); - throw new Error('Not spported'); + // Return a simple unique ID based on command + const cmd = typeof this._command === 'string' ? this._command : this._command?.value ?? ''; + return `shell-${cmd}-${Date.now()}`; } } From e8a157f10953e49b872f31849b321b640f5f1cff Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 09:02:06 -0800 Subject: [PATCH 04/13] Format: apply linter formatting fixes --- src/managers/builtin/uvPythonInstaller.ts | 10 +++++++++- .../managers/builtin/uvPythonInstaller.unit.test.ts | 1 - src/test/mocks/vsc/extHostedTypes.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index eaaa3c7b..8e31e372 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -1,4 +1,12 @@ -import { LogOutputChannel, ProgressLocation, ShellExecution, Task, TaskPanelKind, TaskRevealKind, TaskScope } from 'vscode'; +import { + LogOutputChannel, + ProgressLocation, + ShellExecution, + Task, + TaskPanelKind, + TaskRevealKind, + TaskScope, +} from 'vscode'; import { PythonEnvironmentApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { UvInstallStrings } from '../../common/localize'; diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index fac7e0e0..6ddd7f22 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -172,4 +172,3 @@ suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { // NOTE: Installation functions (installUv, installPythonViaUv, installPythonWithUv) require // VS Code's Task API which cannot be fully mocked in unit tests. // These should be tested via integration tests in a real VS Code environment. - diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index 2542895b..9eb2f697 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1707,7 +1707,7 @@ export class ShellExecution implements vscode.ShellExecution { // } // return hash.digest('hex'); // Return a simple unique ID based on command - const cmd = typeof this._command === 'string' ? this._command : this._command?.value ?? ''; + const cmd = typeof this._command === 'string' ? this._command : (this._command?.value ?? ''); return `shell-${cmd}-${Date.now()}`; } } From 924d7ca77ecc6be6418e92c5a082b1ce8c6473c2 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 09:23:35 -0800 Subject: [PATCH 05/13] Fix lint errors: replace 'as any' with proper type casts --- .../builtin/uvPythonInstaller.unit.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 6ddd7f22..5cef06ff 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel } from 'vscode'; +import { PythonEnvironmentApi } from '../../../api'; import { UvInstallStrings } from '../../../common/localize'; import * as persistentState from '../../../common/persistentState'; import { EventNames } from '../../../common/telemetry/constants'; @@ -16,7 +17,7 @@ import { createMockLogOutputChannel } from '../../mocks/helper'; suite('uvPythonInstaller - promptInstallPythonViaUv', () => { let mockLog: LogOutputChannel; - let mockApi: { refreshEnvironments: sinon.SinonStub }; + let mockApi: Partial; let isUvInstalledStub: sinon.SinonStub; let showInformationMessageStub: sinon.SinonStub; let sendTelemetryEventStub: sinon.SinonStub; @@ -44,7 +45,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { 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); + const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); assert.strictEqual(result, false); assert(showInformationMessageStub.notCalled, 'Should not show message when dont ask again is set'); @@ -56,7 +57,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(undefined); // User dismissed - await promptInstallPythonViaUv('activation', mockApi as any, mockLog); + await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); assert( showInformationMessageStub.calledWith( @@ -73,7 +74,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(false); showInformationMessageStub.resolves(undefined); // User dismissed - await promptInstallPythonViaUv('activation', mockApi as any, mockLog); + await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); assert( showInformationMessageStub.calledWith( @@ -90,7 +91,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(UvInstallStrings.dontAskAgain); - const result = await promptInstallPythonViaUv('activation', mockApi as any, mockLog); + 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'); @@ -101,7 +102,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(undefined); // User dismissed - const result = await promptInstallPythonViaUv('activation', mockApi as any, mockLog); + const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); assert.strictEqual(result, false); }); @@ -111,7 +112,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(undefined); - await promptInstallPythonViaUv('createEnvironment', mockApi as any, mockLog); + await promptInstallPythonViaUv('createEnvironment', mockApi as PythonEnvironmentApi, mockLog); assert( sendTelemetryEventStub.calledWith(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { From 639c1cc89a9e1c29ca7432f77faaf643ebc7ae50 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 12:09:35 -0800 Subject: [PATCH 06/13] feat: enhance Python installation prompts and responses via uv --- src/common/localize.ts | 6 ++ src/common/telemetry/constants.ts | 2 +- src/managers/builtin/sysPythonManager.ts | 29 +++++++-- src/managers/builtin/uvPythonInstaller.ts | 61 +++++++++++++------ src/managers/builtin/venvManager.ts | 8 ++- .../builtin/uvPythonInstaller.unit.test.ts | 25 ++++---- 6 files changed, 88 insertions(+), 43 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index e159be93..55913114 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -221,6 +221,12 @@ export namespace UvInstallStrings { export const installingUv = l10n.t('Installing uv...'); export const installingPython = l10n.t('Installing Python via uv...'); export const installComplete = l10n.t('Python installed successfully'); + export function installCompleteWithDetails(version: string, path: string): string { + return l10n.t('Python {0} installed successfully at {1}', version, path); + } + export function installCompleteWithPath(path: string): string { + return l10n.t('Python installed successfully at {0}', path); + } export const installFailed = l10n.t('Failed to install Python'); export const uvInstallFailed = l10n.t('Failed to install uv'); export const dontAskAgain = l10n.t("Don't ask again"); diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index d9120b6e..fa3b63c2 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -128,7 +128,7 @@ export interface IEventNamePropertyMapping { } */ [EventNames.UV_PYTHON_INSTALL_FAILED]: { - stage: 'uvInstall' | 'pythonInstall'; + stage: 'uvInstall' | 'pythonInstall' | 'findPath'; }; /* __GDPR__ diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index b5c231ff..7074e7b7 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -75,7 +75,19 @@ export class SysPythonManager implements EnvironmentManager { // If no Python environments were found, offer to install via uv if (this.collection.length === 0) { - await promptInstallPythonViaUv('activation', this.api, this.log); + const pythonPath = await promptInstallPythonViaUv('activation', this.log); + if (pythonPath) { + const resolved = await resolveSystemPythonEnvironmentPath( + pythonPath, + this.nativeFinder, + this.api, + this, + ); + if (resolved) { + this.collection.push(resolved); + this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); + } + } } this._initialized.resolve(); @@ -234,12 +246,17 @@ export class SysPythonManager implements EnvironmentManager { _scope: CreateEnvironmentScope, _options?: CreateEnvironmentOptions, ): Promise { - const success = await installPythonWithUv(this.api, this.log); + const pythonPath = await installPythonWithUv(this.log); - if (success) { - // Return the latest Python environment after installation - // The installPythonWithUv function already refreshes environments - return getLatest(this.collection); + if (pythonPath) { + // Resolve the installed Python using NativePythonFinder instead of full refresh + const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); + if (resolved) { + // Add to collection and fire change event + this.collection.push(resolved); + this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); + return resolved; + } } return undefined; diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 8e31e372..8f121f1a 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -7,7 +7,6 @@ import { TaskRevealKind, TaskScope, } from 'vscode'; -import { PythonEnvironmentApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { UvInstallStrings } from '../../common/localize'; import { traceError, traceInfo, traceLog } from '../../common/logging'; @@ -141,6 +140,27 @@ export async function installUv(_log?: LogOutputChannel): Promise { return success; } +/** + * Gets the path to the uv-managed Python installation. + * @returns Promise that resolves to the Python path, or undefined if not found + */ +export async function getUvPythonPath(): Promise { + return new Promise((resolve) => { + const chunks: string[] = []; + const proc = spawnProcess('uv', ['python', 'find']); + proc.stdout?.on('data', (data) => chunks.push(data.toString())); + proc.on('error', () => resolve(undefined)); + proc.on('exit', (code) => { + if (code === 0 && chunks.length > 0) { + const pythonPath = chunks.join('').trim(); + resolve(pythonPath || undefined); + } else { + resolve(undefined); + } + }); + }); +} + /** * Installs Python using uv. * @param log Optional log output channel @@ -171,21 +191,19 @@ export async function installPythonViaUv(_log?: LogOutputChannel, version?: stri * Respects the "Don't ask again" setting. * * @param trigger What triggered this prompt ('activation' or 'createEnvironment') - * @param api The Python environment API (used to refresh environments after installation) * @param log Optional log output channel - * @returns Promise that resolves to true if Python was successfully installed + * @returns Promise that resolves to the installed Python path, or undefined if not installed */ export async function promptInstallPythonViaUv( trigger: 'activation' | 'createEnvironment', - api: PythonEnvironmentApi, log?: LogOutputChannel, -): Promise { +): Promise { const state = await getGlobalPersistentState(); const dontAsk = await state.get(UV_INSTALL_PYTHON_DONT_ASK_KEY); if (dontAsk) { traceLog('Skipping Python install prompt: user selected "Don\'t ask again"'); - return false; + return undefined; } sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { trigger }); @@ -205,25 +223,24 @@ export async function promptInstallPythonViaUv( if (result === UvInstallStrings.dontAskAgain) { await state.set(UV_INSTALL_PYTHON_DONT_ASK_KEY, true); traceLog('User selected "Don\'t ask again" for Python install prompt'); - return false; + return undefined; } if (result === UvInstallStrings.installPython) { - return await installPythonWithUv(api, log); + return await installPythonWithUv(log); } - return false; + return undefined; } /** * Installs Python using uv. If uv is not installed, installs it first. * This is the main entry point for programmatic Python installation. * - * @param api The Python environment API (used to refresh environments after installation) * @param log Optional log output channel - * @returns Promise that resolves to true if Python was successfully installed + * @returns Promise that resolves to the installed Python path, or undefined on failure */ -export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOutputChannel): Promise { +export async function installPythonWithUv(log?: LogOutputChannel): Promise { const uvInstalled = await isUvInstalled(log); sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled }); @@ -243,7 +260,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu if (!uvSuccess) { sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvInstall' }); showErrorMessage(UvInstallStrings.uvInstallFailed); - return false; + return undefined; } } @@ -252,17 +269,23 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu if (!pythonSuccess) { sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' }); showErrorMessage(UvInstallStrings.installFailed); - return false; + return undefined; } - // Step 3: Refresh environments to detect newly installed Python - traceInfo('Refreshing environments after Python installation...'); - await api.refreshEnvironments(undefined); + // Step 3: Get the installed Python path using uv python find + const pythonPath = await getUvPythonPath(); + if (!pythonPath) { + traceError('Python installed but could not find the path via uv python find'); + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'findPath' }); + showErrorMessage(UvInstallStrings.installFailed); + return undefined; + } + traceInfo(`Python installed successfully at: ${pythonPath}`); sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_COMPLETED); - showInformationMessage(UvInstallStrings.installComplete); + showInformationMessage(UvInstallStrings.installCompleteWithPath(pythonPath)); - return true; + return pythonPath; }, ); } diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 2afe0f88..d56b5fd9 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -147,9 +147,11 @@ export class VenvManager implements EnvironmentManager { // If no Python environments found, offer to install Python via uv if (globals.length === 0) { - const installed = await promptInstallPythonViaUv('createEnvironment', this.api, this.log); - if (installed) { - // Re-fetch environments after installation + const installedPath = await promptInstallPythonViaUv('createEnvironment', this.log); + if (installedPath) { + // Refresh environments to detect the newly installed Python + await this.api.refreshEnvironments(undefined); + // Re-fetch environments after refresh globals = await this.api.getEnvironments('global'); // Update globalEnv reference if we found any Python 3.x environments const python3Envs = globals.filter((e) => e.version.startsWith('3.')); diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 5cef06ff..6ee7ad7d 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -1,7 +1,6 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel } from 'vscode'; -import { PythonEnvironmentApi } from '../../../api'; import { UvInstallStrings } from '../../../common/localize'; import * as persistentState from '../../../common/persistentState'; import { EventNames } from '../../../common/telemetry/constants'; @@ -17,7 +16,6 @@ import { createMockLogOutputChannel } from '../../mocks/helper'; suite('uvPythonInstaller - promptInstallPythonViaUv', () => { let mockLog: LogOutputChannel; - let mockApi: Partial; let isUvInstalledStub: sinon.SinonStub; let showInformationMessageStub: sinon.SinonStub; let sendTelemetryEventStub: sinon.SinonStub; @@ -25,7 +23,6 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { setup(() => { mockLog = createMockLogOutputChannel(); - mockApi = { refreshEnvironments: sinon.stub().resolves() }; mockState = { get: sinon.stub(), @@ -42,12 +39,12 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { sinon.restore(); }); - test('should return false when "Don\'t ask again" is set', async () => { + test('should return undefined when "Don\'t ask again" is set', async () => { mockState.get.resolves(true); - const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); + const result = await promptInstallPythonViaUv('activation', mockLog); - assert.strictEqual(result, false); + assert.strictEqual(result, undefined); assert(showInformationMessageStub.notCalled, 'Should not show message when dont ask again is set'); assert(sendTelemetryEventStub.notCalled, 'Should not send telemetry when skipping prompt'); }); @@ -57,7 +54,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(undefined); // User dismissed - await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); + await promptInstallPythonViaUv('activation', mockLog); assert( showInformationMessageStub.calledWith( @@ -74,7 +71,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(false); showInformationMessageStub.resolves(undefined); // User dismissed - await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); + await promptInstallPythonViaUv('activation', mockLog); assert( showInformationMessageStub.calledWith( @@ -91,20 +88,20 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(UvInstallStrings.dontAskAgain); - const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog); + const result = await promptInstallPythonViaUv('activation', mockLog); - assert.strictEqual(result, false); + assert.strictEqual(result, undefined); 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 () => { + test('should return undefined 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 PythonEnvironmentApi, mockLog); + const result = await promptInstallPythonViaUv('activation', mockLog); - assert.strictEqual(result, false); + assert.strictEqual(result, undefined); }); test('should send telemetry with correct trigger', async () => { @@ -112,7 +109,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { isUvInstalledStub.resolves(true); showInformationMessageStub.resolves(undefined); - await promptInstallPythonViaUv('createEnvironment', mockApi as PythonEnvironmentApi, mockLog); + await promptInstallPythonViaUv('createEnvironment', mockLog); assert( sendTelemetryEventStub.calledWith(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { From 6f116b083bc6cacca45d477fe3189161c6dc13bb Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 12:15:02 -0800 Subject: [PATCH 07/13] feat: add Python version selection for installation via uv --- src/common/localize.ts | 4 + src/managers/builtin/sysPythonManager.ts | 13 ++- src/managers/builtin/uvPythonInstaller.ts | 128 +++++++++++++++++++++- 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index 55913114..e5d63bc1 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -231,4 +231,8 @@ export namespace UvInstallStrings { export const uvInstallFailed = l10n.t('Failed to install uv'); export const dontAskAgain = l10n.t("Don't ask again"); export const clickToInstallPython = l10n.t('No Python found, click to install'); + export const selectPythonVersion = l10n.t('Select Python version to install'); + export const installed = l10n.t('installed'); + export const fetchingVersions = l10n.t('Fetching available Python versions...'); + export const failedToFetchVersions = l10n.t('Failed to fetch available Python versions'); } diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 7074e7b7..301f22ba 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -30,7 +30,7 @@ import { setSystemEnvForWorkspaces, } from './cache'; import { refreshPythons, resolveSystemPythonEnvironmentPath } from './utils'; -import { installPythonWithUv, promptInstallPythonViaUv } from './uvPythonInstaller'; +import { installPythonWithUv, promptInstallPythonViaUv, selectPythonVersionToInstall } from './uvPythonInstaller'; export class SysPythonManager implements EnvironmentManager { private collection: PythonEnvironment[] = []; @@ -240,13 +240,20 @@ export class SysPythonManager implements EnvironmentManager { /** * Installs a global Python using uv. - * This method installs uv if not present, then uses it to install Python. + * This method shows a QuickPick to select the Python version, then installs it. */ async create( _scope: CreateEnvironmentScope, _options?: CreateEnvironmentOptions, ): Promise { - const pythonPath = await installPythonWithUv(this.log); + // Show QuickPick to select Python version + const selectedVersion = await selectPythonVersionToInstall(); + if (!selectedVersion) { + // User cancelled + return undefined; + } + + const pythonPath = await installPythonWithUv(this.log, selectedVersion); if (pythonPath) { // Resolve the installed Python using NativePythonFinder instead of full refresh diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 8f121f1a..056ebdfc 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -1,6 +1,7 @@ import { LogOutputChannel, ProgressLocation, + QuickPickItem, ShellExecution, Task, TaskPanelKind, @@ -16,11 +17,30 @@ import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred } from '../../common/utils/deferred'; import { isWindows } from '../../common/utils/platformUtils'; -import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; +import { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis'; import { isUvInstalled, resetUvInstallationCache } from './helpers'; const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; +/** + * Represents a Python version from uv python list + */ +export interface UvPythonVersion { + key: string; + version: string; + version_parts: { + major: number; + minor: number; + patch: number; + }; + path: string | null; + url: string | null; + os: string; + variant: string; + implementation: string; + arch: string; +} + /** * Checks if a command is available on the system. */ @@ -142,12 +162,17 @@ export async function installUv(_log?: LogOutputChannel): Promise { /** * Gets the path to the uv-managed Python installation. + * @param version Optional Python version to find (e.g., "3.12") * @returns Promise that resolves to the Python path, or undefined if not found */ -export async function getUvPythonPath(): Promise { +export async function getUvPythonPath(version?: string): Promise { return new Promise((resolve) => { const chunks: string[] = []; - const proc = spawnProcess('uv', ['python', 'find']); + const args = ['python', 'find']; + if (version) { + args.push(version); + } + const proc = spawnProcess('uv', args); proc.stdout?.on('data', (data) => chunks.push(data.toString())); proc.on('error', () => resolve(undefined)); proc.on('exit', (code) => { @@ -161,6 +186,96 @@ export async function getUvPythonPath(): Promise { }); } +/** + * Gets available Python versions from uv. + * @returns Promise that resolves to an array of Python versions + */ +export async function getAvailablePythonVersions(): Promise { + return new Promise((resolve) => { + const chunks: string[] = []; + const proc = spawnProcess('uv', ['python', 'list', '--output-format', 'json']); + proc.stdout?.on('data', (data) => chunks.push(data.toString())); + proc.on('error', () => resolve([])); + proc.on('exit', (code) => { + if (code === 0 && chunks.length > 0) { + try { + const versions = JSON.parse(chunks.join('')) as UvPythonVersion[]; + resolve(versions); + } catch { + traceError('Failed to parse uv python list output'); + resolve([]); + } + } else { + resolve([]); + } + }); + }); +} + +interface PythonVersionQuickPickItem extends QuickPickItem { + version: string; + isInstalled: boolean; +} + +/** + * Shows a QuickPick to select a Python version to install. + * @returns Promise that resolves to the selected version string, or undefined if cancelled + */ +export async function selectPythonVersionToInstall(): Promise { + const versions = await withProgress( + { + location: ProgressLocation.Notification, + title: UvInstallStrings.fetchingVersions, + }, + async () => getAvailablePythonVersions(), + ); + + if (versions.length === 0) { + showErrorMessage(UvInstallStrings.failedToFetchVersions); + return undefined; + } + + // Filter to only default variant (not freethreaded) and group by minor version + const seenMinorVersions = new Set(); + const items: PythonVersionQuickPickItem[] = []; + + for (const v of versions) { + // Only include default variant CPython + if (v.variant !== 'default' || v.implementation !== 'cpython') { + continue; + } + + // Create a minor version key (e.g., "3.13") + const minorKey = `${v.version_parts.major}.${v.version_parts.minor}`; + + // Only show the latest patch for each minor version (they come sorted from uv) + if (seenMinorVersions.has(minorKey)) { + continue; + } + seenMinorVersions.add(minorKey); + + const isInstalled = v.path !== null; + items.push({ + label: `Python ${v.version}`, + description: isInstalled ? `$(check) ${UvInstallStrings.installed}` : undefined, + detail: isInstalled ? v.path ?? undefined : undefined, + version: v.version, + isInstalled, + }); + } + + const selected = await showQuickPick(items, { + placeHolder: UvInstallStrings.selectPythonVersion, + ignoreFocusOut: true, + }); + + if (!selected) { + return undefined; + } + + return selected.version; +} + /** * Installs Python using uv. * @param log Optional log output channel @@ -238,9 +353,10 @@ export async function promptInstallPythonViaUv( * This is the main entry point for programmatic Python installation. * * @param log Optional log output channel + * @param version Optional Python version to install (e.g., "3.12") * @returns Promise that resolves to the installed Python path, or undefined on failure */ -export async function installPythonWithUv(log?: LogOutputChannel): Promise { +export async function installPythonWithUv(log?: LogOutputChannel, version?: string): Promise { const uvInstalled = await isUvInstalled(log); sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled }); @@ -265,7 +381,7 @@ export async function installPythonWithUv(log?: LogOutputChannel): Promise Date: Fri, 6 Feb 2026 12:21:11 -0800 Subject: [PATCH 08/13] feat: enhance getUvPythonPath to list only installed uv-managed Python versions --- src/managers/builtin/uvPythonInstaller.ts | 33 +++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 056ebdfc..2d348c87 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -162,23 +162,40 @@ export async function installUv(_log?: LogOutputChannel): Promise { /** * Gets the path to the uv-managed Python installation. - * @param version Optional Python version to find (e.g., "3.12") + * Uses `uv python list --only-installed --managed-python` to find only uv-installed Pythons. + * @param version Optional Python version to find (e.g., "3.12"). If not specified, returns the latest. * @returns Promise that resolves to the Python path, or undefined if not found */ export async function getUvPythonPath(version?: string): Promise { return new Promise((resolve) => { const chunks: string[] = []; - const args = ['python', 'find']; - if (version) { - args.push(version); - } + // Use --only-installed --managed-python to find only uv-managed Pythons + const args = ['python', 'list', '--only-installed', '--managed-python', '--output-format', 'json']; const proc = spawnProcess('uv', args); proc.stdout?.on('data', (data) => chunks.push(data.toString())); proc.on('error', () => resolve(undefined)); proc.on('exit', (code) => { if (code === 0 && chunks.length > 0) { - const pythonPath = chunks.join('').trim(); - resolve(pythonPath || undefined); + try { + const versions = JSON.parse(chunks.join('')) as UvPythonVersion[]; + if (versions.length === 0) { + resolve(undefined); + return; + } + + // If version specified, find matching one (e.g., "3.12" matches "3.12.11") + if (version) { + const match = versions.find((v) => v.version.startsWith(version) && v.path); + resolve(match?.path ?? undefined); + } else { + // Return the first (latest) installed Python + const installed = versions.find((v) => v.path); + resolve(installed?.path ?? undefined); + } + } catch { + traceError('Failed to parse uv python list output'); + resolve(undefined); + } } else { resolve(undefined); } @@ -258,7 +275,7 @@ export async function selectPythonVersionToInstall(): Promise Date: Fri, 6 Feb 2026 15:29:31 -0800 Subject: [PATCH 09/13] fix: address PR review comments - Use monotonic counter instead of Date.now() in ShellExecution.computeId() for deterministic IDs - Export UV_INSTALL_PYTHON_DONT_ASK_KEY constant and use in tests - Capitalize 'Python' in noEnvFound localized string - Re-check uv availability after installation to handle PATH issues - Add uvInstallRestartRequired localized string for when uv is not on PATH --- src/common/localize.ts | 7 ++++--- src/managers/builtin/uvPythonInstaller.ts | 11 ++++++++++- .../managers/builtin/uvPythonInstaller.unit.test.ts | 5 +++-- src/test/mocks/vsc/extHostedTypes.ts | 7 +++++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index e5d63bc1..ed516df6 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -97,7 +97,7 @@ export namespace VenvManagerStrings { export const venvErrorNoPython3 = l10n.t('Did not find any base Python 3'); export const noEnvClickToCreate = l10n.t('No environment found, click to create'); - export const noEnvFound = l10n.t('No python environments found.'); + export const noEnvFound = l10n.t('No Python environments found.'); export const createEnvironment = l10n.t('Create Environment'); export const venvName = l10n.t('Enter a name for the virtual environment'); @@ -228,8 +228,9 @@ export namespace UvInstallStrings { return l10n.t('Python installed successfully at {0}', path); } export const installFailed = l10n.t('Failed to install Python'); - export const uvInstallFailed = l10n.t('Failed to install uv'); - export const dontAskAgain = l10n.t("Don't ask again"); + export const uvInstallFailed = l10n.t('Failed to install uv'); export const uvInstallRestartRequired = l10n.t( + 'uv was installed but may not be available in the current terminal. Please restart VS Code or open a new terminal and try again.', + ); export const dontAskAgain = l10n.t("Don't ask again"); export const clickToInstallPython = l10n.t('No Python found, click to install'); export const selectPythonVersion = l10n.t('Select Python version to install'); export const installed = l10n.t('installed'); diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 2d348c87..fcc64986 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -20,7 +20,7 @@ import { isWindows } from '../../common/utils/platformUtils'; import { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis'; import { isUvInstalled, resetUvInstallationCache } from './helpers'; -const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; +export const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; /** * Represents a Python version from uv python list @@ -395,6 +395,15 @@ export async function installPythonWithUv(log?: LogOutputChannel, version?: stri showErrorMessage(UvInstallStrings.uvInstallFailed); return undefined; } + + // Verify uv is now available on PATH + const uvNowInstalled = await isUvInstalled(log); + if (!uvNowInstalled) { + traceError('uv installed but not found on PATH - may require terminal restart'); + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvNotOnPath' }); + showErrorMessage(UvInstallStrings.uvInstallRestartRequired); + return undefined; + } } // Step 2: Install Python via uv diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 6ee7ad7d..835930bd 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -11,6 +11,7 @@ import { clearDontAskAgain, isDontAskAgainSet, promptInstallPythonViaUv, + UV_INSTALL_PYTHON_DONT_ASK_KEY, } from '../../../managers/builtin/uvPythonInstaller'; import { createMockLogOutputChannel } from '../../mocks/helper'; @@ -91,7 +92,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { const result = await promptInstallPythonViaUv('activation', mockLog); assert.strictEqual(result, undefined); - assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', true), 'Should set dont ask flag'); + assert(mockState.set.calledWith(UV_INSTALL_PYTHON_DONT_ASK_KEY, true), 'Should set dont ask flag'); }); test('should return undefined when user dismisses the dialog', async () => { @@ -163,7 +164,7 @@ suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { test('clearDontAskAgain should set flag to false', async () => { await clearDontAskAgain(); - assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', false), 'Should clear the flag'); + assert(mockState.set.calledWith(UV_INSTALL_PYTHON_DONT_ASK_KEY, false), 'Should clear the flag'); }); }); diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index 9eb2f697..79a75f7a 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1613,6 +1613,8 @@ export class ProcessExecution implements vscode.ProcessExecution { } export class ShellExecution implements vscode.ShellExecution { + private static idCounter = 0; + private _commandLine = ''; private _command: string | vscode.ShellQuotedString = ''; @@ -1706,9 +1708,10 @@ export class ShellExecution implements vscode.ShellExecution { // } // } // return hash.digest('hex'); - // Return a simple unique ID based on command + // Return a deterministic unique ID based on command and a monotonic counter const cmd = typeof this._command === 'string' ? this._command : (this._command?.value ?? ''); - return `shell-${cmd}-${Date.now()}`; + const argsStr = this._args?.map((a) => (typeof a === 'string' ? a : a.value)).join(',') ?? ''; + return `shell-${cmd}-${argsStr}-${ShellExecution.idCounter++}`; } } From 48d11610b8ccf4b5e79ea26056ae29edae97d28e Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 15:43:46 -0800 Subject: [PATCH 10/13] formatting --- src/common/localize.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index ed516df6..a44f9a27 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -228,9 +228,11 @@ export namespace UvInstallStrings { return l10n.t('Python installed successfully at {0}', path); } export const installFailed = l10n.t('Failed to install Python'); - export const uvInstallFailed = l10n.t('Failed to install uv'); export const uvInstallRestartRequired = l10n.t( + export const uvInstallFailed = l10n.t('Failed to install uv'); + export const uvInstallRestartRequired = l10n.t( 'uv was installed but may not be available in the current terminal. Please restart VS Code or open a new terminal and try again.', - ); export const dontAskAgain = l10n.t("Don't ask again"); + ); + export const dontAskAgain = l10n.t("Don't ask again"); export const clickToInstallPython = l10n.t('No Python found, click to install'); export const selectPythonVersion = l10n.t('Select Python version to install'); export const installed = l10n.t('installed'); From 49de5f8b2a01d3dd22fd4d55ca5617e1c4798f80 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 15:49:17 -0800 Subject: [PATCH 11/13] fix: add uvNotOnPath to telemetry stage type --- src/common/telemetry/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index fa3b63c2..508cbaea 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -128,7 +128,7 @@ export interface IEventNamePropertyMapping { } */ [EventNames.UV_PYTHON_INSTALL_FAILED]: { - stage: 'uvInstall' | 'pythonInstall' | 'findPath'; + stage: 'uvInstall' | 'uvNotOnPath' | 'pythonInstall' | 'findPath'; }; /* __GDPR__ From 16fe13ea4528641b182b6fd8fc89201f8c16d1a1 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 17:30:18 -0800 Subject: [PATCH 12/13] Address PR review comments: modal prompt, memoize computeId, update globalEnv, fix comments, add tests --- src/common/localize.ts | 6 +- src/managers/builtin/sysPythonManager.ts | 6 +- src/managers/builtin/uvPythonInstaller.ts | 5 +- .../features/views/treeViewItems.unit.test.ts | 87 ++++++++++++++++++- .../builtin/uvPythonInstaller.unit.test.ts | 2 + src/test/mocks/vsc/extHostedTypes.ts | 13 ++- 6 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index a44f9a27..d79f67b9 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -213,9 +213,11 @@ export namespace ActivationStrings { export namespace UvInstallStrings { export const noPythonFound = l10n.t('No Python installation found'); - export const installPythonPrompt = l10n.t('No Python found. Would you like to install Python using uv?'); + export const installPythonPrompt = l10n.t( + 'No Python found. Would you like to install Python using uv? This will download and run an installer from https://astral.sh.', + ); export const installPythonAndUvPrompt = l10n.t( - 'No Python found. Would you like to install uv and use it to install Python?', + 'No Python found. Would you like to install uv and use it to install Python? This will download and run an installer from https://astral.sh.', ); export const installPython = l10n.t('Install Python'); export const installingUv = l10n.t('Installing uv...'); diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 301f22ba..ace1abc8 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -85,6 +85,8 @@ export class SysPythonManager implements EnvironmentManager { ); if (resolved) { this.collection.push(resolved); + this.globalEnv = resolved; + await setSystemEnvForGlobal(resolved.environmentPath.fsPath); this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); } } @@ -259,8 +261,10 @@ export class SysPythonManager implements EnvironmentManager { // Resolve the installed Python using NativePythonFinder instead of full refresh const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); if (resolved) { - // Add to collection and fire change event + // Add to collection, update global env, and fire change event this.collection.push(resolved); + this.globalEnv = resolved; + await setSystemEnvForGlobal(resolved.environmentPath.fsPath); this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); return resolved; } diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index fcc64986..00c99d8f 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -348,6 +348,7 @@ export async function promptInstallPythonViaUv( const result = await showInformationMessage( promptMessage, + { modal: true }, UvInstallStrings.installPython, UvInstallStrings.dontAskAgain, ); @@ -414,10 +415,10 @@ export async function installPythonWithUv(log?: LogOutputChannel, version?: stri return undefined; } - // Step 3: Get the installed Python path using uv python find + // Step 3: Get the installed Python path using uv-managed Python listing const pythonPath = await getUvPythonPath(version); if (!pythonPath) { - traceError('Python installed but could not find the path via uv python find'); + traceError('Python installed but could not find the path via uv python list'); sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'findPath' }); showErrorMessage(UvInstallStrings.installFailed); return undefined; diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index 929f76f2..a6452e94 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -1,7 +1,8 @@ import * as assert from 'assert'; -import { EnvManagerTreeItem, PythonEnvTreeItem } from '../../../features/views/treeViewItems'; +import { EnvManagerTreeItem, NoPythonEnvTreeItem, PythonEnvTreeItem } from '../../../features/views/treeViewItems'; import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; import { Uri } from 'vscode'; +import { UvInstallStrings, VenvManagerStrings } from '../../../common/localize'; suite('Test TreeView Items', () => { suite('EnvManagerTreeItem', () => { @@ -238,4 +239,88 @@ suite('Test TreeView Items', () => { assert.equal(item.treeItem.label, env.displayName); }); }); + + suite('NoPythonEnvTreeItem', () => { + test('System manager with create: shows install Python label', () => { + const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { + name: 'system', + displayName: 'Global', + description: 'test', + preferredPackageManagerId: 'pip', + refresh: () => Promise.resolve(), + getEnvironments: () => Promise.resolve([]), + resolve: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + create: () => Promise.resolve(undefined), + }); + const managerItem = new EnvManagerTreeItem(manager); + const item = new NoPythonEnvTreeItem(managerItem); + + assert.equal(item.treeItem.label, UvInstallStrings.clickToInstallPython); + assert.ok(item.treeItem.command, 'Should have a command'); + assert.equal(item.treeItem.command?.title, UvInstallStrings.installPython); + assert.equal(item.treeItem.command?.command, 'python-envs.create'); + }); + + test('Non-system manager with create: shows create environment label', () => { + const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { + name: 'venv', + displayName: 'Venv', + description: 'test', + preferredPackageManagerId: 'pip', + refresh: () => Promise.resolve(), + getEnvironments: () => Promise.resolve([]), + resolve: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + create: () => Promise.resolve(undefined), + }); + const managerItem = new EnvManagerTreeItem(manager); + const item = new NoPythonEnvTreeItem(managerItem); + + assert.equal(item.treeItem.label, VenvManagerStrings.noEnvClickToCreate); + assert.ok(item.treeItem.command, 'Should have a command'); + assert.equal(item.treeItem.command?.title, VenvManagerStrings.createEnvironment); + assert.equal(item.treeItem.command?.command, 'python-envs.create'); + }); + + test('Manager without create: shows no env found label', () => { + const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { + name: 'test', + displayName: 'Test', + description: 'test', + preferredPackageManagerId: 'pip', + refresh: () => Promise.resolve(), + getEnvironments: () => Promise.resolve([]), + resolve: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + }); + const managerItem = new EnvManagerTreeItem(manager); + const item = new NoPythonEnvTreeItem(managerItem); + + assert.equal(item.treeItem.label, VenvManagerStrings.noEnvFound); + assert.equal(item.treeItem.command, undefined, 'Should not have a command'); + }); + + test('System manager without create: shows no env found label', () => { + const manager = new InternalEnvironmentManager('ms-python.python:test-manager', { + name: 'system', + displayName: 'Global', + description: 'test', + preferredPackageManagerId: 'pip', + refresh: () => Promise.resolve(), + getEnvironments: () => Promise.resolve([]), + resolve: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + }); + const managerItem = new EnvManagerTreeItem(manager); + const item = new NoPythonEnvTreeItem(managerItem); + + assert.equal(item.treeItem.label, VenvManagerStrings.noEnvFound); + assert.equal(item.treeItem.command, undefined, 'Should not have a command'); + }); + }); }); diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 835930bd..e086f7bb 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -60,6 +60,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { assert( showInformationMessageStub.calledWith( UvInstallStrings.installPythonPrompt, + { modal: true }, UvInstallStrings.installPython, UvInstallStrings.dontAskAgain, ), @@ -77,6 +78,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { assert( showInformationMessageStub.calledWith( UvInstallStrings.installPythonAndUvPrompt, + { modal: true }, UvInstallStrings.installPython, UvInstallStrings.dontAskAgain, ), diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index 79a75f7a..c929f1db 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1615,6 +1615,8 @@ export class ProcessExecution implements vscode.ProcessExecution { export class ShellExecution implements vscode.ShellExecution { private static idCounter = 0; + private _cachedId: string | undefined; + private _commandLine = ''; private _command: string | vscode.ShellQuotedString = ''; @@ -1708,10 +1710,13 @@ export class ShellExecution implements vscode.ShellExecution { // } // } // return hash.digest('hex'); - // Return a deterministic unique ID based on command and a monotonic counter - const cmd = typeof this._command === 'string' ? this._command : (this._command?.value ?? ''); - const argsStr = this._args?.map((a) => (typeof a === 'string' ? a : a.value)).join(',') ?? ''; - return `shell-${cmd}-${argsStr}-${ShellExecution.idCounter++}`; + // Memoize the computed ID per instance to ensure consistent task matching + if (this._cachedId === undefined) { + const cmd = typeof this._command === 'string' ? this._command : (this._command?.value ?? ''); + const argsStr = this._args?.map((a) => (typeof a === 'string' ? a : a.value)).join(',') ?? ''; + this._cachedId = `shell-${cmd}-${argsStr}-${ShellExecution.idCounter++}`; + } + return this._cachedId; } } From eb4d249a508159b3f55c41277f19cd1aeb119e1d Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 6 Feb 2026 17:30:48 -0800 Subject: [PATCH 13/13] formatting --- src/test/features/views/treeViewItems.unit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index a6452e94..e9e1f33a 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -1,8 +1,8 @@ import * as assert from 'assert'; -import { EnvManagerTreeItem, NoPythonEnvTreeItem, PythonEnvTreeItem } from '../../../features/views/treeViewItems'; -import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; import { Uri } from 'vscode'; import { UvInstallStrings, VenvManagerStrings } from '../../../common/localize'; +import { EnvManagerTreeItem, NoPythonEnvTreeItem, PythonEnvTreeItem } from '../../../features/views/treeViewItems'; +import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; suite('Test TreeView Items', () => { suite('EnvManagerTreeItem', () => {