Skip to content

Commit 639c1cc

Browse files
committed
feat: enhance Python installation prompts and responses via uv
1 parent 924d7ca commit 639c1cc

File tree

6 files changed

+88
-43
lines changed

6 files changed

+88
-43
lines changed

src/common/localize.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ export namespace UvInstallStrings {
221221
export const installingUv = l10n.t('Installing uv...');
222222
export const installingPython = l10n.t('Installing Python via uv...');
223223
export const installComplete = l10n.t('Python installed successfully');
224+
export function installCompleteWithDetails(version: string, path: string): string {
225+
return l10n.t('Python {0} installed successfully at {1}', version, path);
226+
}
227+
export function installCompleteWithPath(path: string): string {
228+
return l10n.t('Python installed successfully at {0}', path);
229+
}
224230
export const installFailed = l10n.t('Failed to install Python');
225231
export const uvInstallFailed = l10n.t('Failed to install uv');
226232
export const dontAskAgain = l10n.t("Don't ask again");

src/common/telemetry/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export interface IEventNamePropertyMapping {
128128
}
129129
*/
130130
[EventNames.UV_PYTHON_INSTALL_FAILED]: {
131-
stage: 'uvInstall' | 'pythonInstall';
131+
stage: 'uvInstall' | 'pythonInstall' | 'findPath';
132132
};
133133

134134
/* __GDPR__

src/managers/builtin/sysPythonManager.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,19 @@ export class SysPythonManager implements EnvironmentManager {
7575

7676
// If no Python environments were found, offer to install via uv
7777
if (this.collection.length === 0) {
78-
await promptInstallPythonViaUv('activation', this.api, this.log);
78+
const pythonPath = await promptInstallPythonViaUv('activation', this.log);
79+
if (pythonPath) {
80+
const resolved = await resolveSystemPythonEnvironmentPath(
81+
pythonPath,
82+
this.nativeFinder,
83+
this.api,
84+
this,
85+
);
86+
if (resolved) {
87+
this.collection.push(resolved);
88+
this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]);
89+
}
90+
}
7991
}
8092

8193
this._initialized.resolve();
@@ -234,12 +246,17 @@ export class SysPythonManager implements EnvironmentManager {
234246
_scope: CreateEnvironmentScope,
235247
_options?: CreateEnvironmentOptions,
236248
): Promise<PythonEnvironment | undefined> {
237-
const success = await installPythonWithUv(this.api, this.log);
249+
const pythonPath = await installPythonWithUv(this.log);
238250

239-
if (success) {
240-
// Return the latest Python environment after installation
241-
// The installPythonWithUv function already refreshes environments
242-
return getLatest(this.collection);
251+
if (pythonPath) {
252+
// Resolve the installed Python using NativePythonFinder instead of full refresh
253+
const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this);
254+
if (resolved) {
255+
// Add to collection and fire change event
256+
this.collection.push(resolved);
257+
this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]);
258+
return resolved;
259+
}
243260
}
244261

245262
return undefined;

src/managers/builtin/uvPythonInstaller.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
TaskRevealKind,
88
TaskScope,
99
} from 'vscode';
10-
import { PythonEnvironmentApi } from '../../api';
1110
import { spawnProcess } from '../../common/childProcess.apis';
1211
import { UvInstallStrings } from '../../common/localize';
1312
import { traceError, traceInfo, traceLog } from '../../common/logging';
@@ -141,6 +140,27 @@ export async function installUv(_log?: LogOutputChannel): Promise<boolean> {
141140
return success;
142141
}
143142

143+
/**
144+
* Gets the path to the uv-managed Python installation.
145+
* @returns Promise that resolves to the Python path, or undefined if not found
146+
*/
147+
export async function getUvPythonPath(): Promise<string | undefined> {
148+
return new Promise((resolve) => {
149+
const chunks: string[] = [];
150+
const proc = spawnProcess('uv', ['python', 'find']);
151+
proc.stdout?.on('data', (data) => chunks.push(data.toString()));
152+
proc.on('error', () => resolve(undefined));
153+
proc.on('exit', (code) => {
154+
if (code === 0 && chunks.length > 0) {
155+
const pythonPath = chunks.join('').trim();
156+
resolve(pythonPath || undefined);
157+
} else {
158+
resolve(undefined);
159+
}
160+
});
161+
});
162+
}
163+
144164
/**
145165
* Installs Python using uv.
146166
* @param log Optional log output channel
@@ -171,21 +191,19 @@ export async function installPythonViaUv(_log?: LogOutputChannel, version?: stri
171191
* Respects the "Don't ask again" setting.
172192
*
173193
* @param trigger What triggered this prompt ('activation' or 'createEnvironment')
174-
* @param api The Python environment API (used to refresh environments after installation)
175194
* @param log Optional log output channel
176-
* @returns Promise that resolves to true if Python was successfully installed
195+
* @returns Promise that resolves to the installed Python path, or undefined if not installed
177196
*/
178197
export async function promptInstallPythonViaUv(
179198
trigger: 'activation' | 'createEnvironment',
180-
api: PythonEnvironmentApi,
181199
log?: LogOutputChannel,
182-
): Promise<boolean> {
200+
): Promise<string | undefined> {
183201
const state = await getGlobalPersistentState();
184202
const dontAsk = await state.get<boolean>(UV_INSTALL_PYTHON_DONT_ASK_KEY);
185203

186204
if (dontAsk) {
187205
traceLog('Skipping Python install prompt: user selected "Don\'t ask again"');
188-
return false;
206+
return undefined;
189207
}
190208

191209
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { trigger });
@@ -205,25 +223,24 @@ export async function promptInstallPythonViaUv(
205223
if (result === UvInstallStrings.dontAskAgain) {
206224
await state.set(UV_INSTALL_PYTHON_DONT_ASK_KEY, true);
207225
traceLog('User selected "Don\'t ask again" for Python install prompt');
208-
return false;
226+
return undefined;
209227
}
210228

211229
if (result === UvInstallStrings.installPython) {
212-
return await installPythonWithUv(api, log);
230+
return await installPythonWithUv(log);
213231
}
214232

215-
return false;
233+
return undefined;
216234
}
217235

218236
/**
219237
* Installs Python using uv. If uv is not installed, installs it first.
220238
* This is the main entry point for programmatic Python installation.
221239
*
222-
* @param api The Python environment API (used to refresh environments after installation)
223240
* @param log Optional log output channel
224-
* @returns Promise that resolves to true if Python was successfully installed
241+
* @returns Promise that resolves to the installed Python path, or undefined on failure
225242
*/
226-
export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOutputChannel): Promise<boolean> {
243+
export async function installPythonWithUv(log?: LogOutputChannel): Promise<string | undefined> {
227244
const uvInstalled = await isUvInstalled(log);
228245

229246
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_STARTED, undefined, { uvAlreadyInstalled: uvInstalled });
@@ -243,7 +260,7 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu
243260
if (!uvSuccess) {
244261
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvInstall' });
245262
showErrorMessage(UvInstallStrings.uvInstallFailed);
246-
return false;
263+
return undefined;
247264
}
248265
}
249266

@@ -252,17 +269,23 @@ export async function installPythonWithUv(api: PythonEnvironmentApi, log?: LogOu
252269
if (!pythonSuccess) {
253270
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'pythonInstall' });
254271
showErrorMessage(UvInstallStrings.installFailed);
255-
return false;
272+
return undefined;
256273
}
257274

258-
// Step 3: Refresh environments to detect newly installed Python
259-
traceInfo('Refreshing environments after Python installation...');
260-
await api.refreshEnvironments(undefined);
275+
// Step 3: Get the installed Python path using uv python find
276+
const pythonPath = await getUvPythonPath();
277+
if (!pythonPath) {
278+
traceError('Python installed but could not find the path via uv python find');
279+
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'findPath' });
280+
showErrorMessage(UvInstallStrings.installFailed);
281+
return undefined;
282+
}
261283

284+
traceInfo(`Python installed successfully at: ${pythonPath}`);
262285
sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_COMPLETED);
263-
showInformationMessage(UvInstallStrings.installComplete);
286+
showInformationMessage(UvInstallStrings.installCompleteWithPath(pythonPath));
264287

265-
return true;
288+
return pythonPath;
266289
},
267290
);
268291
}

src/managers/builtin/venvManager.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ export class VenvManager implements EnvironmentManager {
147147

148148
// If no Python environments found, offer to install Python via uv
149149
if (globals.length === 0) {
150-
const installed = await promptInstallPythonViaUv('createEnvironment', this.api, this.log);
151-
if (installed) {
152-
// Re-fetch environments after installation
150+
const installedPath = await promptInstallPythonViaUv('createEnvironment', this.log);
151+
if (installedPath) {
152+
// Refresh environments to detect the newly installed Python
153+
await this.api.refreshEnvironments(undefined);
154+
// Re-fetch environments after refresh
153155
globals = await this.api.getEnvironments('global');
154156
// Update globalEnv reference if we found any Python 3.x environments
155157
const python3Envs = globals.filter((e) => e.version.startsWith('3.'));

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

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import assert from 'assert';
22
import * as sinon from 'sinon';
33
import { LogOutputChannel } from 'vscode';
4-
import { PythonEnvironmentApi } from '../../../api';
54
import { UvInstallStrings } from '../../../common/localize';
65
import * as persistentState from '../../../common/persistentState';
76
import { EventNames } from '../../../common/telemetry/constants';
@@ -17,15 +16,13 @@ import { createMockLogOutputChannel } from '../../mocks/helper';
1716

1817
suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
1918
let mockLog: LogOutputChannel;
20-
let mockApi: Partial<PythonEnvironmentApi>;
2119
let isUvInstalledStub: sinon.SinonStub;
2220
let showInformationMessageStub: sinon.SinonStub;
2321
let sendTelemetryEventStub: sinon.SinonStub;
2422
let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub };
2523

2624
setup(() => {
2725
mockLog = createMockLogOutputChannel();
28-
mockApi = { refreshEnvironments: sinon.stub().resolves() };
2926

3027
mockState = {
3128
get: sinon.stub(),
@@ -42,12 +39,12 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
4239
sinon.restore();
4340
});
4441

45-
test('should return false when "Don\'t ask again" is set', async () => {
42+
test('should return undefined when "Don\'t ask again" is set', async () => {
4643
mockState.get.resolves(true);
4744

48-
const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog);
45+
const result = await promptInstallPythonViaUv('activation', mockLog);
4946

50-
assert.strictEqual(result, false);
47+
assert.strictEqual(result, undefined);
5148
assert(showInformationMessageStub.notCalled, 'Should not show message when dont ask again is set');
5249
assert(sendTelemetryEventStub.notCalled, 'Should not send telemetry when skipping prompt');
5350
});
@@ -57,7 +54,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
5754
isUvInstalledStub.resolves(true);
5855
showInformationMessageStub.resolves(undefined); // User dismissed
5956

60-
await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog);
57+
await promptInstallPythonViaUv('activation', mockLog);
6158

6259
assert(
6360
showInformationMessageStub.calledWith(
@@ -74,7 +71,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
7471
isUvInstalledStub.resolves(false);
7572
showInformationMessageStub.resolves(undefined); // User dismissed
7673

77-
await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog);
74+
await promptInstallPythonViaUv('activation', mockLog);
7875

7976
assert(
8077
showInformationMessageStub.calledWith(
@@ -91,28 +88,28 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => {
9188
isUvInstalledStub.resolves(true);
9289
showInformationMessageStub.resolves(UvInstallStrings.dontAskAgain);
9390

94-
const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog);
91+
const result = await promptInstallPythonViaUv('activation', mockLog);
9592

96-
assert.strictEqual(result, false);
93+
assert.strictEqual(result, undefined);
9794
assert(mockState.set.calledWith('python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK', true), 'Should set dont ask flag');
9895
});
9996

100-
test('should return false when user dismisses the dialog', async () => {
97+
test('should return undefined when user dismisses the dialog', async () => {
10198
mockState.get.resolves(false);
10299
isUvInstalledStub.resolves(true);
103100
showInformationMessageStub.resolves(undefined); // User dismissed
104101

105-
const result = await promptInstallPythonViaUv('activation', mockApi as PythonEnvironmentApi, mockLog);
102+
const result = await promptInstallPythonViaUv('activation', mockLog);
106103

107-
assert.strictEqual(result, false);
104+
assert.strictEqual(result, undefined);
108105
});
109106

110107
test('should send telemetry with correct trigger', async () => {
111108
mockState.get.resolves(false);
112109
isUvInstalledStub.resolves(true);
113110
showInformationMessageStub.resolves(undefined);
114111

115-
await promptInstallPythonViaUv('createEnvironment', mockApi as PythonEnvironmentApi, mockLog);
112+
await promptInstallPythonViaUv('createEnvironment', mockLog);
116113

117114
assert(
118115
sendTelemetryEventStub.calledWith(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, {

0 commit comments

Comments
 (0)