Skip to content

Commit ee11740

Browse files
Prefer Python Install Manager for Windows Python acquisition (#1935)
## Summary - prefer an existing Python Install Manager on Windows for base-Python acquisition, while retaining uv when PyManager is absent - share provider selection across Global creation, project venv creation, activation-time acquisition, and PEP 723 inline-script setup - verify the actual installed CPython interpreter, serialize PyManager mutations, prevent unsafe downgrades, and pause package watchers during in-place runtime updates - supplement Global discovery with verified PyManager-owned runtimes while preserving existing uv-managed interpreter behavior - surface provider-specific cancellation and failure states without starting a second installer or publishing partial selections ## Validation - `npm run lint` - `npm run compile-tests` - `npm run unittest` — 3,306 passing, 9 pending - `npm run compile` - `npm run smoke-test` — 30 passing, 1 pending - `npm run e2e-test` — 4 passing - non-interactive integration suite — 60 passing, 5 pending - multi-root integration suite — 3 passing, 1 pending - native Windows PyManager probes succeeded for installed, online, and `--one` JSON listings (82 online catalogue entries) The full integration run reached 60 passing and 8 pending, with the existing interactive environment-creation case timing out because the headless VS Code test host cannot answer the real Global Python version picker. The equivalent provider picker and creation flows were verified manually in an Extension Development Host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 86c5747 commit ee11740

29 files changed

Lines changed: 5677 additions & 189 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ The following environment managers are supported out of the box:
5252
| conda | ✅ | ✅ | ✅ |
5353
| pyenv | ✅ | | |
5454
| poetry | ✅ | | |
55-
| system | ✅ | | |
55+
| system | ✅ | ✅ | |
5656
| pipenv | ✅ | | |
5757

5858
**Legend:**

‎src/common/inlineScript/routingRegistry.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ export type InlineScriptSetupOutcome =
3131
readonly kind: 'failed';
3232
readonly category: InlineScriptEnvErrorCategory;
3333
readonly requiresPython?: string;
34+
/** Optional installer-specific explanation, already localized for the setup UI. */
35+
readonly message?: string;
36+
readonly alreadyReported?: boolean;
3437
}
35-
| { readonly kind: 'cancelled' }
38+
| { readonly kind: 'cancelled'; readonly message?: string }
3639
| { readonly kind: 'skipped' };
3740

3841
interface ScriptRoutingState {

‎src/common/installerProcess.ts‎

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { ChildProcessWithoutNullStreams } from 'child_process';
5+
import { StringDecoder } from 'string_decoder';
6+
import { CancellationError, CancellationToken, Disposable, l10n } from 'vscode';
7+
import { spawnProcess } from './childProcess.apis';
8+
import { traceVerbose } from './logging';
9+
10+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
11+
const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
12+
const TERMINATION_GRACE_MS = 1000;
13+
14+
export interface InstallerProcessOptions {
15+
readonly timeoutMs?: number;
16+
readonly cancellationToken?: CancellationToken;
17+
readonly onOutput?: (text: string) => void;
18+
readonly maxOutputBytes?: number;
19+
}
20+
21+
export interface InstallerProcessResult {
22+
readonly stdout: string;
23+
readonly stderr: string;
24+
}
25+
26+
export class InstallerProcessError extends Error {
27+
/** Diagnostic output, kept out of the user-facing message and telemetry. */
28+
public stderr = '';
29+
/**
30+
* Describes a process failure without including arguments or potentially sensitive output.
31+
* Uncertainty also covers descendants or external work that can survive the direct child.
32+
*/
33+
constructor(
34+
readonly kind: 'spawn' | 'exit' | 'timeout' | 'output-limit',
35+
readonly exitCode?: number | null,
36+
readonly processMayStillBeRunning = false,
37+
readonly cause?: unknown,
38+
) {
39+
const messages = {
40+
spawn: l10n.t('The Python installer process could not be started.'),
41+
exit: l10n.t('The Python installer process failed.'),
42+
timeout: l10n.t('The Python installer process timed out.'),
43+
'output-limit': l10n.t('The Python installer process exceeded its output limit.'),
44+
};
45+
super(messages[kind]);
46+
this.name = 'InstallerProcessError';
47+
}
48+
}
49+
50+
class InstallerCancellationError extends CancellationError {
51+
constructor(readonly processMayStillBeRunning: boolean) {
52+
super();
53+
}
54+
}
55+
56+
function traceLateError(): void {
57+
traceVerbose('Received an installer process or stream error after completion.');
58+
}
59+
60+
/**
61+
* Runs an executable directly with piped stdio, bounded output and a finite timeout.
62+
* @param executable Executable path or command; never interpreted by a shell.
63+
* @param args Separate, unmodified arguments.
64+
* @param options Limits, cancellation, and opt-in raw output forwarding.
65+
* @returns Separate UTF-8 stdout/stderr after the child and its streams close.
66+
* Cancellation and forced stops wait briefly for close, but cannot guarantee that
67+
* descendants or external work (such as downloads) have stopped.
68+
*/
69+
export async function runInstallerProcess(
70+
executable: string,
71+
args: readonly string[],
72+
options: InstallerProcessOptions = {},
73+
): Promise<InstallerProcessResult> {
74+
const { cancellationToken, onOutput } = options;
75+
if (cancellationToken?.isCancellationRequested) {
76+
throw new InstallerCancellationError(false);
77+
}
78+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
79+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
80+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2147483647) {
81+
throw new RangeError(l10n.t('The installer timeout must be a finite, non-negative number of milliseconds.'));
82+
}
83+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) {
84+
throw new RangeError(l10n.t('The installer output limit must be a finite, non-negative number of bytes.'));
85+
}
86+
87+
let child: ChildProcessWithoutNullStreams;
88+
try {
89+
child = spawnProcess(executable, [...args], { shell: false, windowsHide: true, stdio: 'pipe' });
90+
} catch (error) {
91+
throw new InstallerProcessError('spawn', undefined, false, error);
92+
}
93+
94+
return new Promise<InstallerProcessResult>((resolve, reject) => {
95+
const stdout = { decoder: new StringDecoder('utf8'), chunks: [] as string[] };
96+
const stderr = { decoder: new StringDecoder('utf8'), chunks: [] as string[] };
97+
let bytes = 0;
98+
let settled = false;
99+
let closed = false;
100+
let exited = false;
101+
let exitCode: number | null | undefined;
102+
let failure: InstallerProcessError | InstallerCancellationError | undefined;
103+
let timeout: ReturnType<typeof setTimeout> | undefined;
104+
let grace: ReturnType<typeof setTimeout> | undefined;
105+
let cancellation: Disposable | undefined;
106+
107+
function cleanup(): void {
108+
clearTimeout(timeout);
109+
clearTimeout(grace);
110+
cancellation?.dispose();
111+
child.removeListener('exit', onExit);
112+
child.removeListener('close', onClose);
113+
child.stdout.removeListener('data', onStdout);
114+
child.stderr.removeListener('data', onStderr);
115+
// Keep only stateless error guards: queued errors (notably stdin EPIPE)
116+
// can arrive after settlement or while the local pipes are destroyed.
117+
child.on('error', traceLateError);
118+
child.removeListener('error', onProcessError);
119+
child.stdin.on('error', traceLateError);
120+
child.stdin.removeListener('error', onStdinError);
121+
for (const stream of [child.stdout, child.stderr]) {
122+
stream.on('error', traceLateError);
123+
stream.removeListener('error', onStreamError);
124+
}
125+
child.stdin.destroy();
126+
child.stdout.destroy();
127+
child.stderr.destroy();
128+
}
129+
130+
function finish(): void {
131+
if (settled) {
132+
return;
133+
}
134+
settled = true;
135+
cleanup();
136+
if (failure) {
137+
if (failure instanceof InstallerProcessError) {
138+
failure.stderr = stderr.chunks.join('');
139+
}
140+
reject(failure);
141+
} else {
142+
resolve({ stdout: stdout.chunks.join(''), stderr: stderr.chunks.join('') });
143+
}
144+
stdout.chunks.length = 0;
145+
stderr.chunks.length = 0;
146+
}
147+
148+
function stop(error: InstallerProcessError | InstallerCancellationError, signalChild = true): void {
149+
if (settled || failure) {
150+
return;
151+
}
152+
failure = error;
153+
clearTimeout(timeout);
154+
if (closed) {
155+
return;
156+
}
157+
grace = setTimeout(finish, TERMINATION_GRACE_MS);
158+
if (signalChild && !exited) {
159+
try {
160+
if (!child.kill()) {
161+
traceVerbose('Installer termination signal was not accepted; waiting for close.');
162+
}
163+
} catch (_error) {
164+
traceVerbose('Installer termination signaling failed; waiting for close.');
165+
}
166+
}
167+
}
168+
169+
function append(target: typeof stdout, text: string): void {
170+
if (!text || failure || settled) {
171+
return;
172+
}
173+
target.chunks.push(text);
174+
try {
175+
onOutput?.(text);
176+
} catch (error) {
177+
stop(new InstallerProcessError('exit', exitCode, !closed, error));
178+
}
179+
}
180+
181+
function capture(target: typeof stdout, data: Buffer | string): void {
182+
if (failure || settled) {
183+
return;
184+
}
185+
const buffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
186+
bytes += buffer.length;
187+
if (bytes > maxOutputBytes) {
188+
stop(new InstallerProcessError('output-limit', exitCode, true));
189+
return;
190+
}
191+
append(target, target.decoder.write(buffer));
192+
}
193+
194+
function onStdout(data: Buffer | string): void {
195+
capture(stdout, data);
196+
}
197+
198+
function onStderr(data: Buffer | string): void {
199+
capture(stderr, data);
200+
}
201+
202+
function onExit(code: number | null): void {
203+
exited = true;
204+
exitCode = code;
205+
}
206+
207+
function onClose(code: number | null, signal: NodeJS.Signals | null): void {
208+
closed = true;
209+
exitCode = code;
210+
if (!failure && (code !== 0 || signal)) {
211+
failure = new InstallerProcessError('exit', code, Boolean(signal));
212+
}
213+
if (!failure) {
214+
append(stdout, stdout.decoder.end());
215+
append(stderr, stderr.decoder.end());
216+
}
217+
finish();
218+
}
219+
220+
function onProcessError(error: Error): void {
221+
const mayBeRunning = child.pid !== undefined;
222+
stop(new InstallerProcessError('spawn', exitCode, mayBeRunning, error), mayBeRunning);
223+
}
224+
225+
function onStreamError(error: Error): void {
226+
stop(new InstallerProcessError('exit', exitCode, !closed, error));
227+
}
228+
229+
function onStdinError(error: Error): void {
230+
if ('code' in error && (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED')) {
231+
traceVerbose('Installer closed stdin before consuming all input; waiting for close.');
232+
return;
233+
}
234+
onStreamError(error);
235+
}
236+
237+
child.on('error', onProcessError);
238+
child.on('exit', onExit);
239+
child.on('close', onClose);
240+
child.stdin.on('error', onStdinError);
241+
child.stdout.on('error', onStreamError);
242+
child.stderr.on('error', onStreamError);
243+
child.stdout.on('data', onStdout);
244+
child.stderr.on('data', onStderr);
245+
timeout = setTimeout(() => stop(new InstallerProcessError('timeout', exitCode, true)), timeoutMs);
246+
cancellation = cancellationToken?.onCancellationRequested(() => stop(new InstallerCancellationError(true)));
247+
// Cover cancellation between the initial check and subscribing, including
248+
// tokens whose registration invokes the callback synchronously.
249+
if (settled) {
250+
cancellation?.dispose();
251+
return;
252+
}
253+
if (cancellationToken?.isCancellationRequested) {
254+
stop(new InstallerCancellationError(true));
255+
}
256+
if (!failure) {
257+
try {
258+
child.stdin.end();
259+
} catch (error) {
260+
onStdinError(error instanceof Error ? error : new Error(l10n.t('Installer input could not be written.')));
261+
}
262+
}
263+
});
264+
}

‎src/common/localize.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,61 @@ export namespace ActivationStrings {
316316
);
317317
}
318318

319+
export namespace PythonInstallStrings {
320+
export const pymanager = l10n.t('Python Install Manager');
321+
export const fetchingVersions = l10n.t('Finding available Python versions...');
322+
export const selectVersion = l10n.t('Select Python version to install');
323+
export const installed = l10n.t('installed');
324+
export const installFailed = l10n.t('Python installation did not complete. See the Python Environments output for details.');
325+
export const managerUnusable = l10n.t(
326+
'Python Install Manager is unavailable or could not be verified. Check its installation and configuration, then try again. uv was not started.',
327+
);
328+
export const catalogueFailed = l10n.t(
329+
'Could not find Python versions using Python Install Manager. Check its configured source or network access and try again. uv was not started.',
330+
);
331+
export const noCompatiblePython = l10n.t('No compatible Python was found in the selected installer\'s catalogue.');
332+
export const verificationFailed = l10n.t(
333+
'A usable Python interpreter matching the request could not be verified. See the Python Environments output.',
334+
);
335+
export const discoveryFailed = l10n.t(
336+
'Python was installed but could not be verified. Refresh environments and try again.',
337+
);
338+
export const timedOut = l10n.t(
339+
'Python installation timed out. Background work may still be running. No alternate installer was started; check the Python Environments output before retrying.',
340+
);
341+
export const cancelled = l10n.t(
342+
'Python installation was canceled. Any runtime already installed is kept, and background downloads may still finish. No alternate installer was started.',
343+
);
344+
export function installPrompt(version: string, requirement?: string): string {
345+
return requirement
346+
? l10n.t(
347+
'No installed Python satisfies this script\'s requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.',
348+
requirement,
349+
version,
350+
)
351+
: l10n.t(
352+
'Install Python {0} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.',
353+
version,
354+
);
355+
}
356+
export function installAction(version: string): string {
357+
return l10n.t('Install / Update Python {0}', version);
358+
}
359+
export function installing(version: string): string {
360+
return l10n.t('Installing Python {0} using Python Install Manager...', version);
361+
}
362+
export function runtimeConflict(installed: string, requested: string): string {
363+
return l10n.t(
364+
'Python Install Manager already has Python {0} in the slot required for Python {1}. The existing runtime will not be downgraded or forcibly replaced. Select another compatible interpreter or manage that runtime explicitly, then try again.',
365+
installed,
366+
requested,
367+
);
368+
}
369+
export function complete(executable: string): string {
370+
return l10n.t('Python is ready at {0}', executable);
371+
}
372+
}
373+
319374
export namespace UvInstallStrings {
320375
export const noPythonFound = l10n.t('No Python installation found');
321376
export const installPythonPrompt = l10n.t(

‎src/common/telemetry/constants.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export enum EventNames {
1515
UV_PYTHON_INSTALL_STARTED = 'UV.PYTHON_INSTALL_STARTED',
1616
UV_PYTHON_INSTALL_COMPLETED = 'UV.PYTHON_INSTALL_COMPLETED',
1717
UV_PYTHON_INSTALL_FAILED = 'UV.PYTHON_INSTALL_FAILED',
18+
PYTHON_INSTALLER_OPERATION = 'PYTHON_INSTALLER.OPERATION',
1819

1920
PACKAGE_MANAGEMENT = 'PACKAGE_MANAGEMENT',
2021
ADD_PROJECT = 'ADD_PROJECT',
@@ -412,6 +413,23 @@ export interface IEventNamePropertyMapping {
412413
stage: 'uvInstall' | 'uvNotOnPath' | 'pythonInstall' | 'findPath';
413414
};
414415

416+
/* __GDPR__
417+
"python_installer.operation": {
418+
"provider": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
419+
"trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
420+
"phase": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
421+
"reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
422+
"duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "StellaHuang95" }
423+
}
424+
*/
425+
[EventNames.PYTHON_INSTALLER_OPERATION]: {
426+
provider: 'pymanager' | 'uv';
427+
trigger: 'activation' | 'createEnvironment' | 'inlineScript' | 'globalCreate';
428+
phase: 'prompted' | 'started' | 'completed' | 'failed' | 'cancelled' | 'declined' | 'reused';
429+
reason?: 'provider-unusable' | 'catalogue-failed' | 'no-compatible-python' | 'runtime-conflict' |
430+
'install-failed' | 'verification-failed';
431+
};
432+
415433
/* __GDPR__
416434
"package_management": {
417435
"managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },

0 commit comments

Comments
 (0)