diff --git a/src/common/localize.ts b/src/common/localize.ts index 7191a184..d79f67b9 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,34 @@ 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? 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? 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...'); + 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 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'); + export const fetchingVersions = l10n.t('Fetching available Python versions...'); + export const failedToFetchVersions = l10n.t('Failed to fetch available Python versions'); +} 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/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index efd58ca6..508cbaea 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' | 'uvNotOnPath' | 'pythonInstall' | 'findPath'; + }; + /* __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..ace1abc8 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, selectPythonVersionToInstall } from './uvPythonInstaller'; export class SysPythonManager implements EnvironmentManager { private collection: PythonEnvironment[] = []; @@ -70,6 +73,25 @@ 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) { + 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.globalEnv = resolved; + await setSystemEnvForGlobal(resolved.environmentPath.fsPath); + this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); + } + } + } + this._initialized.resolve(); } @@ -218,6 +240,39 @@ export class SysPythonManager implements EnvironmentManager { return resolved; } + /** + * Installs a global Python using uv. + * This method shows a QuickPick to select the Python version, then installs it. + */ + async create( + _scope: CreateEnvironmentScope, + _options?: CreateEnvironmentOptions, + ): Promise { + // 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 + const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); + if (resolved) { + // 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; + } + } + + 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..00c99d8f --- /dev/null +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -0,0 +1,450 @@ +import { + LogOutputChannel, + ProgressLocation, + QuickPickItem, + ShellExecution, + Task, + TaskPanelKind, + TaskRevealKind, + TaskScope, +} from 'vscode'; +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, 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 { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis'; +import { isUvInstalled, resetUvInstallationCache } from './helpers'; + +export 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. + */ +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'], + }; +} + +// 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 + * @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 = onDidEndTaskProcess((e) => { + if (e.execution.task === task) { + 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) { + traceError(`Task "${name}" failed:`, err); + return false; + } finally { + clearTimeout(timeoutId); + disposable.dispose(); + } +} + +/** + * 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; +} + +/** + * Gets the path to the uv-managed Python installation. + * 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[] = []; + // 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) { + 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); + } + }); + }); +} + +/** + * 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 + * @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 log Optional log output channel + * @returns Promise that resolves to the installed Python path, or undefined if not installed + */ +export async function promptInstallPythonViaUv( + trigger: 'activation' | 'createEnvironment', + 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 undefined; + } + + 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, + { modal: true }, + 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 undefined; + } + + if (result === UvInstallStrings.installPython) { + return await installPythonWithUv(log); + } + + 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 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, version?: string): Promise { + const uvInstalled = await isUvInstalled(log); + + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled }); + + return await 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' }); + 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 + const pythonSuccess = await installPythonViaUv(log, version); + if (!pythonSuccess) { + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' }); + showErrorMessage(UvInstallStrings.installFailed); + return undefined; + } + + // 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 list'); + 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.installCompleteWithPath(pythonPath)); + + return pythonPath; + }, + ); +} + +/** + * 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..d56b5fd9 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,26 @@ 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 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.')); + 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); + } + } + } + let result: CreateEnvironmentResult | undefined = undefined; if (options?.quickCreate) { // error on missing information diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index 929f76f2..e9e1f33a 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 { 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', () => { @@ -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 new file mode 100644 index 00000000..e086f7bb --- /dev/null +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -0,0 +1,175 @@ +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, + UV_INSTALL_PYTHON_DONT_ASK_KEY, +} from '../../../managers/builtin/uvPythonInstaller'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('uvPythonInstaller - promptInstallPythonViaUv', () => { + let mockLog: LogOutputChannel; + 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(); + + 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 undefined when "Don\'t ask again" is set', async () => { + mockState.get.resolves(true); + + const result = await promptInstallPythonViaUv('activation', mockLog); + + 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'); + }); + + 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', mockLog); + + assert( + showInformationMessageStub.calledWith( + UvInstallStrings.installPythonPrompt, + { modal: true }, + 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', mockLog); + + assert( + showInformationMessageStub.calledWith( + UvInstallStrings.installPythonAndUvPrompt, + { modal: true }, + 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', mockLog); + + assert.strictEqual(result, undefined); + 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 () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); // User dismissed + + const result = await promptInstallPythonViaUv('activation', mockLog); + + assert.strictEqual(result, undefined); + }); + + test('should send telemetry with correct trigger', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('createEnvironment', 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(UV_INSTALL_PYTHON_DONT_ASK_KEY, 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..c929f1db 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -1613,6 +1613,10 @@ 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 = ''; @@ -1706,7 +1710,13 @@ export class ShellExecution implements vscode.ShellExecution { // } // } // return hash.digest('hex'); - throw new Error('Not spported'); + // 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; } }