Skip to content

Commit d0cc27c

Browse files
eleanorjboydCopilot
andcommitted
ux: clarify missing uv prerequisite
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7ef4968 commit d0cc27c

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

‎src/common/localize.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,10 @@ export namespace UvInstallStrings {
383383
export const clickToInstallPython = l10n.t('No Python found, click to install');
384384
export const selectPythonVersion = l10n.t('Select Python version to install');
385385
export const installUvForVersionLookupPrompt = l10n.t(
386-
'The Python Environments extension uses uv to view and install available Python versions. Install uv now? This will download and run an installer from https://astral.sh. Alternatively, cancel and install Python yourself.',
386+
'The Global environment manager uses uv to find and install Python versions. uv was not found. Install uv to continue, or install Python another way.',
387387
);
388+
export const otherPythonInstallationOptions = l10n.t('Other installation options');
389+
export const close = l10n.t('Close');
388390
export const installed = l10n.t('installed');
389391
export const fetchingVersions = l10n.t('Fetching available Python versions...');
390392
export const failedToFetchVersions = l10n.t('Failed to fetch available Python versions');

‎src/managers/builtin/uvPythonInstaller.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
TaskScope,
1212
} from 'vscode';
1313
import { spawnProcess } from '../../common/childProcess.apis';
14+
import { launchBrowser } from '../../common/env.apis';
15+
import { showErrorMessageWithLogs } from '../../common/errors/utils';
1416
import { Common, UvInstallStrings } from '../../common/localize';
1517
import { traceError, traceInfo, traceLog, traceWarn } from '../../common/logging';
1618
import { getGlobalPersistentState } from '../../common/persistentState';
@@ -28,6 +30,8 @@ export const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_
2830

2931
const MAX_PROMPT_DETAIL_LENGTH = 120;
3032
const TASK_TIMEOUT_MS = 5 * 60 * 1000;
33+
const PYTHON_INSTALLATION_OPTIONS_URL =
34+
'https://code.visualstudio.com/docs/python/python-tutorial#_install-a-python-interpreter';
3135

3236
// Accept only numeric release segments before forwarding script-controlled input to uv.
3337
const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.dev\d+)?$/i;
@@ -235,7 +239,17 @@ async function ensureUvForVersionLookup(
235239
if (selection !== UvInstallStrings.installUv) {
236240
return 'declined';
237241
}
242+
return installUvForVersionLookup(log);
243+
}
244+
245+
async function installUvForVersionLookup(
246+
log?: LogOutputChannel,
247+
showInstallFailure = false,
248+
): Promise<EnsureUvForInlineScriptVersionLookupResult> {
238249
if (!(await installUv(log))) {
250+
if (showInstallFailure) {
251+
await showErrorMessageWithLogs(UvInstallStrings.uvInstallFailed, log);
252+
}
239253
return 'failed';
240254
}
241255
if (await isUvInstalled(log)) {
@@ -252,7 +266,25 @@ async function ensureUvForVersionLookup(
252266
* @returns Whether uv is available for version lookup.
253267
*/
254268
export async function ensureUvForPythonVersionLookup(log?: LogOutputChannel): Promise<boolean> {
255-
return (await ensureUvForVersionLookup(UvInstallStrings.installUvForVersionLookupPrompt, log)) === 'available';
269+
if (await isUvInstalled(log)) {
270+
return true;
271+
}
272+
273+
const selection = await showInformationMessage(
274+
UvInstallStrings.installUvForVersionLookupPrompt,
275+
{ modal: true },
276+
UvInstallStrings.installUv,
277+
UvInstallStrings.otherPythonInstallationOptions,
278+
UvInstallStrings.close,
279+
);
280+
if (selection === UvInstallStrings.otherPythonInstallationOptions) {
281+
await launchBrowser(PYTHON_INSTALLATION_OPTIONS_URL);
282+
return false;
283+
}
284+
if (selection !== UvInstallStrings.installUv) {
285+
return false;
286+
}
287+
return (await installUvForVersionLookup(log, true)) === 'available';
256288
}
257289

258290
export async function ensureUvForInlineScriptVersionLookupDetailed(

‎src/test/managers/builtin/uvPythonInstaller.unit.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path';
44
import * as sinon from 'sinon';
55
import { CancellationToken, LogOutputChannel, ShellExecution, TaskExecution, TaskProcessEndEvent } from 'vscode';
66
import * as childProcessApis from '../../../common/childProcess.apis';
7+
import * as envApis from '../../../common/env.apis';
78
import { Common, UvInstallStrings } from '../../../common/localize';
89
import * as persistentState from '../../../common/persistentState';
910
import { EventNames } from '../../../common/telemetry/constants';
@@ -36,6 +37,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
3637
let isUvInstalledStub: sinon.SinonStub;
3738
let showErrorMessageStub: sinon.SinonStub;
3839
let showInformationMessageStub: sinon.SinonStub;
40+
let launchBrowserStub: sinon.SinonStub;
3941
let sendTelemetryEventStub: sinon.SinonStub;
4042
let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub };
4143

@@ -53,6 +55,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
5355
sinon.stub(helpers, 'getUvExecutable').resolves('uv');
5456
showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage');
5557
showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage');
58+
launchBrowserStub = sinon.stub(envApis, 'launchBrowser').resolves(true);
5659
sendTelemetryEventStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
5760
});
5861

@@ -289,9 +292,31 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
289292
UvInstallStrings.installUvForVersionLookupPrompt,
290293
{ modal: true },
291294
UvInstallStrings.installUv,
295+
UvInstallStrings.otherPythonInstallationOptions,
296+
UvInstallStrings.close,
292297
);
293298
});
294299

300+
test('should open alternate Python installation guidance', async () => {
301+
isUvInstalledStub.resolves(false);
302+
showInformationMessageStub.resolves(UvInstallStrings.otherPythonInstallationOptions);
303+
304+
assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false);
305+
sinon.assert.calledOnceWithExactly(
306+
launchBrowserStub,
307+
'https://code.visualstudio.com/docs/python/python-tutorial#_install-a-python-interpreter',
308+
);
309+
});
310+
311+
test('should close global Python version lookup without installing uv', async () => {
312+
isUvInstalledStub.resolves(false);
313+
showInformationMessageStub.resolves(UvInstallStrings.close);
314+
315+
assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false);
316+
sinon.assert.notCalled(launchBrowserStub);
317+
assert.strictEqual(showErrorMessageStub.callCount, 0);
318+
});
319+
295320
test('should install uv for global Python version lookup after consent', async () => {
296321
isUvInstalledStub.onFirstCall().resolves(false);
297322
isUvInstalledStub.onSecondCall().resolves(true);
@@ -303,6 +328,17 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
303328
assert.strictEqual(executeTaskStub.callCount, 1);
304329
});
305330

331+
test('should report a failed uv installation for global Python version lookup', async () => {
332+
isUvInstalledStub.resolves(false);
333+
showInformationMessageStub.resolves(UvInstallStrings.installUv);
334+
showErrorMessageStub.resolves(Common.viewLogs);
335+
stubUvInstallTask(1);
336+
337+
assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false);
338+
sinon.assert.calledOnceWithExactly(showErrorMessageStub, UvInstallStrings.uvInstallFailed, Common.viewLogs);
339+
sinon.assert.calledOnce(mockLog.show as sinon.SinonStub);
340+
});
341+
306342
test('should install uv before fetching versions and selecting Python', async () => {
307343
const order: string[] = [];
308344
isUvInstalledStub.onFirstCall().resolves(false);
@@ -563,6 +599,7 @@ suite('uvPythonInstaller - executable handoff', () => {
563599
let spawnStub: sinon.SinonStub;
564600
let executeTaskStub: sinon.SinonStub;
565601
let telemetryStub: sinon.SinonStub;
602+
let showInformationMessageStub: sinon.SinonStub;
566603
let expectedExecutable: string;
567604
const pythonPath = path.join(home, 'managed-python', 'python');
568605

@@ -588,7 +625,7 @@ suite('uvPythonInstaller - executable handoff', () => {
588625
expectedExecutable = path.join(installDir, 'uv');
589626
telemetryStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
590627
sinon.stub(windowApis, 'showErrorMessage');
591-
sinon.stub(windowApis, 'showInformationMessage');
628+
showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage');
592629
sinon
593630
.stub(windowApis, 'withProgress')
594631
.callsFake(async (_options, task) =>
@@ -662,6 +699,19 @@ suite('uvPythonInstaller - executable handoff', () => {
662699
assert(!telemetryStub.calledWith(EventNames.UV_PYTHON_INSTALL_FAILED));
663700
});
664701

702+
test('uses the installed executable to populate the Global version picker when PATH is unchanged', async () => {
703+
const originalPath = process.env.PATH;
704+
showInformationMessageStub.resolves(UvInstallStrings.installUv);
705+
sinon.stub(windowApis, 'showQuickPick').callsFake(async (items) => (Array.isArray(items) ? items[0] : undefined));
706+
707+
assert.strictEqual(await selectPythonVersionToInstall(), '3.11.15');
708+
709+
assert.strictEqual(process.env.PATH, originalPath);
710+
sinon.assert.calledWith(spawnStub, expectedExecutable, ['--version']);
711+
sinon.assert.calledWith(spawnStub, expectedExecutable, ['python', 'list', '--output-format', 'json']);
712+
assert.strictEqual(await helpers.getUvExecutable(), expectedExecutable);
713+
});
714+
665715
test('does not replace executable resolution after an installer failure', async () => {
666716
taskExitCode = 1;
667717
assert.strictEqual(await installPythonWithUv(), undefined);

0 commit comments

Comments
 (0)