Skip to content

Commit 6f116b0

Browse files
committed
feat: add Python version selection for installation via uv
1 parent 639c1cc commit 6f116b0

File tree

3 files changed

+136
-9
lines changed

3 files changed

+136
-9
lines changed

src/common/localize.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,8 @@ export namespace UvInstallStrings {
231231
export const uvInstallFailed = l10n.t('Failed to install uv');
232232
export const dontAskAgain = l10n.t("Don't ask again");
233233
export const clickToInstallPython = l10n.t('No Python found, click to install');
234+
export const selectPythonVersion = l10n.t('Select Python version to install');
235+
export const installed = l10n.t('installed');
236+
export const fetchingVersions = l10n.t('Fetching available Python versions...');
237+
export const failedToFetchVersions = l10n.t('Failed to fetch available Python versions');
234238
}

src/managers/builtin/sysPythonManager.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
setSystemEnvForWorkspaces,
3131
} from './cache';
3232
import { refreshPythons, resolveSystemPythonEnvironmentPath } from './utils';
33-
import { installPythonWithUv, promptInstallPythonViaUv } from './uvPythonInstaller';
33+
import { installPythonWithUv, promptInstallPythonViaUv, selectPythonVersionToInstall } from './uvPythonInstaller';
3434

3535
export class SysPythonManager implements EnvironmentManager {
3636
private collection: PythonEnvironment[] = [];
@@ -240,13 +240,20 @@ export class SysPythonManager implements EnvironmentManager {
240240

241241
/**
242242
* Installs a global Python using uv.
243-
* This method installs uv if not present, then uses it to install Python.
243+
* This method shows a QuickPick to select the Python version, then installs it.
244244
*/
245245
async create(
246246
_scope: CreateEnvironmentScope,
247247
_options?: CreateEnvironmentOptions,
248248
): Promise<PythonEnvironment | undefined> {
249-
const pythonPath = await installPythonWithUv(this.log);
249+
// Show QuickPick to select Python version
250+
const selectedVersion = await selectPythonVersionToInstall();
251+
if (!selectedVersion) {
252+
// User cancelled
253+
return undefined;
254+
}
255+
256+
const pythonPath = await installPythonWithUv(this.log, selectedVersion);
250257

251258
if (pythonPath) {
252259
// Resolve the installed Python using NativePythonFinder instead of full refresh

src/managers/builtin/uvPythonInstaller.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
LogOutputChannel,
33
ProgressLocation,
4+
QuickPickItem,
45
ShellExecution,
56
Task,
67
TaskPanelKind,
@@ -16,11 +17,30 @@ import { EventNames } from '../../common/telemetry/constants';
1617
import { sendTelemetryEvent } from '../../common/telemetry/sender';
1718
import { createDeferred } from '../../common/utils/deferred';
1819
import { isWindows } from '../../common/utils/platformUtils';
19-
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
20+
import { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis';
2021
import { isUvInstalled, resetUvInstallationCache } from './helpers';
2122

2223
const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK';
2324

25+
/**
26+
* Represents a Python version from uv python list
27+
*/
28+
export interface UvPythonVersion {
29+
key: string;
30+
version: string;
31+
version_parts: {
32+
major: number;
33+
minor: number;
34+
patch: number;
35+
};
36+
path: string | null;
37+
url: string | null;
38+
os: string;
39+
variant: string;
40+
implementation: string;
41+
arch: string;
42+
}
43+
2444
/**
2545
* Checks if a command is available on the system.
2646
*/
@@ -142,12 +162,17 @@ export async function installUv(_log?: LogOutputChannel): Promise<boolean> {
142162

143163
/**
144164
* Gets the path to the uv-managed Python installation.
165+
* @param version Optional Python version to find (e.g., "3.12")
145166
* @returns Promise that resolves to the Python path, or undefined if not found
146167
*/
147-
export async function getUvPythonPath(): Promise<string | undefined> {
168+
export async function getUvPythonPath(version?: string): Promise<string | undefined> {
148169
return new Promise((resolve) => {
149170
const chunks: string[] = [];
150-
const proc = spawnProcess('uv', ['python', 'find']);
171+
const args = ['python', 'find'];
172+
if (version) {
173+
args.push(version);
174+
}
175+
const proc = spawnProcess('uv', args);
151176
proc.stdout?.on('data', (data) => chunks.push(data.toString()));
152177
proc.on('error', () => resolve(undefined));
153178
proc.on('exit', (code) => {
@@ -161,6 +186,96 @@ export async function getUvPythonPath(): Promise<string | undefined> {
161186
});
162187
}
163188

189+
/**
190+
* Gets available Python versions from uv.
191+
* @returns Promise that resolves to an array of Python versions
192+
*/
193+
export async function getAvailablePythonVersions(): Promise<UvPythonVersion[]> {
194+
return new Promise((resolve) => {
195+
const chunks: string[] = [];
196+
const proc = spawnProcess('uv', ['python', 'list', '--output-format', 'json']);
197+
proc.stdout?.on('data', (data) => chunks.push(data.toString()));
198+
proc.on('error', () => resolve([]));
199+
proc.on('exit', (code) => {
200+
if (code === 0 && chunks.length > 0) {
201+
try {
202+
const versions = JSON.parse(chunks.join('')) as UvPythonVersion[];
203+
resolve(versions);
204+
} catch {
205+
traceError('Failed to parse uv python list output');
206+
resolve([]);
207+
}
208+
} else {
209+
resolve([]);
210+
}
211+
});
212+
});
213+
}
214+
215+
interface PythonVersionQuickPickItem extends QuickPickItem {
216+
version: string;
217+
isInstalled: boolean;
218+
}
219+
220+
/**
221+
* Shows a QuickPick to select a Python version to install.
222+
* @returns Promise that resolves to the selected version string, or undefined if cancelled
223+
*/
224+
export async function selectPythonVersionToInstall(): Promise<string | undefined> {
225+
const versions = await withProgress(
226+
{
227+
location: ProgressLocation.Notification,
228+
title: UvInstallStrings.fetchingVersions,
229+
},
230+
async () => getAvailablePythonVersions(),
231+
);
232+
233+
if (versions.length === 0) {
234+
showErrorMessage(UvInstallStrings.failedToFetchVersions);
235+
return undefined;
236+
}
237+
238+
// Filter to only default variant (not freethreaded) and group by minor version
239+
const seenMinorVersions = new Set<string>();
240+
const items: PythonVersionQuickPickItem[] = [];
241+
242+
for (const v of versions) {
243+
// Only include default variant CPython
244+
if (v.variant !== 'default' || v.implementation !== 'cpython') {
245+
continue;
246+
}
247+
248+
// Create a minor version key (e.g., "3.13")
249+
const minorKey = `${v.version_parts.major}.${v.version_parts.minor}`;
250+
251+
// Only show the latest patch for each minor version (they come sorted from uv)
252+
if (seenMinorVersions.has(minorKey)) {
253+
continue;
254+
}
255+
seenMinorVersions.add(minorKey);
256+
257+
const isInstalled = v.path !== null;
258+
items.push({
259+
label: `Python ${v.version}`,
260+
description: isInstalled ? `$(check) ${UvInstallStrings.installed}` : undefined,
261+
detail: isInstalled ? v.path ?? undefined : undefined,
262+
version: v.version,
263+
isInstalled,
264+
});
265+
}
266+
267+
const selected = await showQuickPick(items, {
268+
placeHolder: UvInstallStrings.selectPythonVersion,
269+
ignoreFocusOut: true,
270+
});
271+
272+
if (!selected) {
273+
return undefined;
274+
}
275+
276+
return selected.version;
277+
}
278+
164279
/**
165280
* Installs Python using uv.
166281
* @param log Optional log output channel
@@ -238,9 +353,10 @@ export async function promptInstallPythonViaUv(
238353
* This is the main entry point for programmatic Python installation.
239354
*
240355
* @param log Optional log output channel
356+
* @param version Optional Python version to install (e.g., "3.12")
241357
* @returns Promise that resolves to the installed Python path, or undefined on failure
242358
*/
243-
export async function installPythonWithUv(log?: LogOutputChannel): Promise<string | undefined> {
359+
export async function installPythonWithUv(log?: LogOutputChannel, version?: string): Promise<string | undefined> {
244360
const uvInstalled = await isUvInstalled(log);
245361

246362
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled });
@@ -265,15 +381,15 @@ export async function installPythonWithUv(log?: LogOutputChannel): Promise<strin
265381
}
266382

267383
// Step 2: Install Python via uv
268-
const pythonSuccess = await installPythonViaUv(log);
384+
const pythonSuccess = await installPythonViaUv(log, version);
269385
if (!pythonSuccess) {
270386
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' });
271387
showErrorMessage(UvInstallStrings.installFailed);
272388
return undefined;
273389
}
274390

275391
// Step 3: Get the installed Python path using uv python find
276-
const pythonPath = await getUvPythonPath();
392+
const pythonPath = await getUvPythonPath(version);
277393
if (!pythonPath) {
278394
traceError('Python installed but could not find the path via uv python find');
279395
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'findPath' });

0 commit comments

Comments
 (0)