From d26d944fb3d0b1c8d3d1017ea9785ef04596e14f Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Fri, 9 Oct 2026 13:10:58 -0700 Subject: [PATCH] feat: prefer Python Install Manager on Windows Use an existing Python Install Manager for Windows runtime acquisition while preserving the original uv path when PyManager is absent. Share the provider across Global, venv, and PEP 723 flows, verify installed runtimes, coordinate discovery and package watchers, and preserve cancellation and fallback semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/common/inlineScript/routingRegistry.ts | 5 +- src/common/installerProcess.ts | 264 ++++ src/common/localize.ts | 55 + src/common/telemetry/constants.ts | 18 + src/features/inlineScript/setupEnvironment.ts | 9 +- .../builtin/inlineScript/envManager.ts | 145 +- .../builtin/pymanagerPythonInstaller.ts | 260 ++++ src/managers/builtin/pythonInstaller.ts | 584 ++++++++ src/managers/builtin/pythonInstallerTypes.ts | 52 + .../builtin/pythonRuntimeCandidates.ts | 332 +++++ src/managers/builtin/sysPythonManager.ts | 252 +++- src/managers/builtin/utils.ts | 7 +- src/managers/builtin/uvPythonInstaller.ts | 2 +- src/managers/builtin/venvManager.ts | 41 +- src/managers/common/packageWatcher.ts | 128 +- src/test/common/installerProcess.unit.test.ts | 434 ++++++ .../setupEnvironment.unit.test.ts | 44 +- .../internal/discoveryIsolation.unit.test.ts | 4 +- .../internal/pythonToolManagers.unit.test.ts | 10 +- .../inlineScript/envManager.unit.test.ts | 71 + .../pymanagerPythonInstaller.unit.test.ts | 504 +++++++ .../builtin/pythonInstaller.unit.test.ts | 1179 +++++++++++++++++ .../pythonRuntimeCandidates.unit.test.ts | 779 +++++++++++ .../sysPythonManager.pymanager.unit.test.ts | 387 ++++++ ...sPythonManager.savedSelection.unit.test.ts | 5 +- .../venvManager.createRemove.unit.test.ts | 73 + .../common/packageWatcher.unit.test.ts | 218 ++- src/test/managers/fastPath.get.unit.test.ts | 2 + 29 files changed, 5677 insertions(+), 189 deletions(-) create mode 100644 src/common/installerProcess.ts create mode 100644 src/managers/builtin/pymanagerPythonInstaller.ts create mode 100644 src/managers/builtin/pythonInstaller.ts create mode 100644 src/managers/builtin/pythonInstallerTypes.ts create mode 100644 src/managers/builtin/pythonRuntimeCandidates.ts create mode 100644 src/test/common/installerProcess.unit.test.ts create mode 100644 src/test/managers/builtin/pymanagerPythonInstaller.unit.test.ts create mode 100644 src/test/managers/builtin/pythonInstaller.unit.test.ts create mode 100644 src/test/managers/builtin/pythonRuntimeCandidates.unit.test.ts create mode 100644 src/test/managers/builtin/sysPythonManager.pymanager.unit.test.ts diff --git a/README.md b/README.md index daa6ac77e..cfbf0f9c1 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The following environment managers are supported out of the box: | conda | ✅ | ✅ | ✅ | | pyenv | ✅ | | | | poetry | ✅ | | | -| system | ✅ | | | +| system | ✅ | ✅ | | | pipenv | ✅ | | | **Legend:** diff --git a/src/common/inlineScript/routingRegistry.ts b/src/common/inlineScript/routingRegistry.ts index c1a6e7c19..fafb5706f 100644 --- a/src/common/inlineScript/routingRegistry.ts +++ b/src/common/inlineScript/routingRegistry.ts @@ -31,8 +31,11 @@ export type InlineScriptSetupOutcome = readonly kind: 'failed'; readonly category: InlineScriptEnvErrorCategory; readonly requiresPython?: string; + /** Optional installer-specific explanation, already localized for the setup UI. */ + readonly message?: string; + readonly alreadyReported?: boolean; } - | { readonly kind: 'cancelled' } + | { readonly kind: 'cancelled'; readonly message?: string } | { readonly kind: 'skipped' }; interface ScriptRoutingState { diff --git a/src/common/installerProcess.ts b/src/common/installerProcess.ts new file mode 100644 index 000000000..841a463bd --- /dev/null +++ b/src/common/installerProcess.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ChildProcessWithoutNullStreams } from 'child_process'; +import { StringDecoder } from 'string_decoder'; +import { CancellationError, CancellationToken, Disposable, l10n } from 'vscode'; +import { spawnProcess } from './childProcess.apis'; +import { traceVerbose } from './logging'; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +const TERMINATION_GRACE_MS = 1000; + +export interface InstallerProcessOptions { + readonly timeoutMs?: number; + readonly cancellationToken?: CancellationToken; + readonly onOutput?: (text: string) => void; + readonly maxOutputBytes?: number; +} + +export interface InstallerProcessResult { + readonly stdout: string; + readonly stderr: string; +} + +export class InstallerProcessError extends Error { + /** Diagnostic output, kept out of the user-facing message and telemetry. */ + public stderr = ''; + /** + * Describes a process failure without including arguments or potentially sensitive output. + * Uncertainty also covers descendants or external work that can survive the direct child. + */ + constructor( + readonly kind: 'spawn' | 'exit' | 'timeout' | 'output-limit', + readonly exitCode?: number | null, + readonly processMayStillBeRunning = false, + readonly cause?: unknown, + ) { + const messages = { + spawn: l10n.t('The Python installer process could not be started.'), + exit: l10n.t('The Python installer process failed.'), + timeout: l10n.t('The Python installer process timed out.'), + 'output-limit': l10n.t('The Python installer process exceeded its output limit.'), + }; + super(messages[kind]); + this.name = 'InstallerProcessError'; + } +} + +class InstallerCancellationError extends CancellationError { + constructor(readonly processMayStillBeRunning: boolean) { + super(); + } +} + +function traceLateError(): void { + traceVerbose('Received an installer process or stream error after completion.'); +} + +/** + * Runs an executable directly with piped stdio, bounded output and a finite timeout. + * @param executable Executable path or command; never interpreted by a shell. + * @param args Separate, unmodified arguments. + * @param options Limits, cancellation, and opt-in raw output forwarding. + * @returns Separate UTF-8 stdout/stderr after the child and its streams close. + * Cancellation and forced stops wait briefly for close, but cannot guarantee that + * descendants or external work (such as downloads) have stopped. + */ +export async function runInstallerProcess( + executable: string, + args: readonly string[], + options: InstallerProcessOptions = {}, +): Promise { + const { cancellationToken, onOutput } = options; + if (cancellationToken?.isCancellationRequested) { + throw new InstallerCancellationError(false); + } + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2147483647) { + throw new RangeError(l10n.t('The installer timeout must be a finite, non-negative number of milliseconds.')); + } + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) { + throw new RangeError(l10n.t('The installer output limit must be a finite, non-negative number of bytes.')); + } + + let child: ChildProcessWithoutNullStreams; + try { + child = spawnProcess(executable, [...args], { shell: false, windowsHide: true, stdio: 'pipe' }); + } catch (error) { + throw new InstallerProcessError('spawn', undefined, false, error); + } + + return new Promise((resolve, reject) => { + const stdout = { decoder: new StringDecoder('utf8'), chunks: [] as string[] }; + const stderr = { decoder: new StringDecoder('utf8'), chunks: [] as string[] }; + let bytes = 0; + let settled = false; + let closed = false; + let exited = false; + let exitCode: number | null | undefined; + let failure: InstallerProcessError | InstallerCancellationError | undefined; + let timeout: ReturnType | undefined; + let grace: ReturnType | undefined; + let cancellation: Disposable | undefined; + + function cleanup(): void { + clearTimeout(timeout); + clearTimeout(grace); + cancellation?.dispose(); + child.removeListener('exit', onExit); + child.removeListener('close', onClose); + child.stdout.removeListener('data', onStdout); + child.stderr.removeListener('data', onStderr); + // Keep only stateless error guards: queued errors (notably stdin EPIPE) + // can arrive after settlement or while the local pipes are destroyed. + child.on('error', traceLateError); + child.removeListener('error', onProcessError); + child.stdin.on('error', traceLateError); + child.stdin.removeListener('error', onStdinError); + for (const stream of [child.stdout, child.stderr]) { + stream.on('error', traceLateError); + stream.removeListener('error', onStreamError); + } + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } + + function finish(): void { + if (settled) { + return; + } + settled = true; + cleanup(); + if (failure) { + if (failure instanceof InstallerProcessError) { + failure.stderr = stderr.chunks.join(''); + } + reject(failure); + } else { + resolve({ stdout: stdout.chunks.join(''), stderr: stderr.chunks.join('') }); + } + stdout.chunks.length = 0; + stderr.chunks.length = 0; + } + + function stop(error: InstallerProcessError | InstallerCancellationError, signalChild = true): void { + if (settled || failure) { + return; + } + failure = error; + clearTimeout(timeout); + if (closed) { + return; + } + grace = setTimeout(finish, TERMINATION_GRACE_MS); + if (signalChild && !exited) { + try { + if (!child.kill()) { + traceVerbose('Installer termination signal was not accepted; waiting for close.'); + } + } catch (_error) { + traceVerbose('Installer termination signaling failed; waiting for close.'); + } + } + } + + function append(target: typeof stdout, text: string): void { + if (!text || failure || settled) { + return; + } + target.chunks.push(text); + try { + onOutput?.(text); + } catch (error) { + stop(new InstallerProcessError('exit', exitCode, !closed, error)); + } + } + + function capture(target: typeof stdout, data: Buffer | string): void { + if (failure || settled) { + return; + } + const buffer = typeof data === 'string' ? Buffer.from(data, 'utf8') : data; + bytes += buffer.length; + if (bytes > maxOutputBytes) { + stop(new InstallerProcessError('output-limit', exitCode, true)); + return; + } + append(target, target.decoder.write(buffer)); + } + + function onStdout(data: Buffer | string): void { + capture(stdout, data); + } + + function onStderr(data: Buffer | string): void { + capture(stderr, data); + } + + function onExit(code: number | null): void { + exited = true; + exitCode = code; + } + + function onClose(code: number | null, signal: NodeJS.Signals | null): void { + closed = true; + exitCode = code; + if (!failure && (code !== 0 || signal)) { + failure = new InstallerProcessError('exit', code, Boolean(signal)); + } + if (!failure) { + append(stdout, stdout.decoder.end()); + append(stderr, stderr.decoder.end()); + } + finish(); + } + + function onProcessError(error: Error): void { + const mayBeRunning = child.pid !== undefined; + stop(new InstallerProcessError('spawn', exitCode, mayBeRunning, error), mayBeRunning); + } + + function onStreamError(error: Error): void { + stop(new InstallerProcessError('exit', exitCode, !closed, error)); + } + + function onStdinError(error: Error): void { + if ('code' in error && (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED')) { + traceVerbose('Installer closed stdin before consuming all input; waiting for close.'); + return; + } + onStreamError(error); + } + + child.on('error', onProcessError); + child.on('exit', onExit); + child.on('close', onClose); + child.stdin.on('error', onStdinError); + child.stdout.on('error', onStreamError); + child.stderr.on('error', onStreamError); + child.stdout.on('data', onStdout); + child.stderr.on('data', onStderr); + timeout = setTimeout(() => stop(new InstallerProcessError('timeout', exitCode, true)), timeoutMs); + cancellation = cancellationToken?.onCancellationRequested(() => stop(new InstallerCancellationError(true))); + // Cover cancellation between the initial check and subscribing, including + // tokens whose registration invokes the callback synchronously. + if (settled) { + cancellation?.dispose(); + return; + } + if (cancellationToken?.isCancellationRequested) { + stop(new InstallerCancellationError(true)); + } + if (!failure) { + try { + child.stdin.end(); + } catch (error) { + onStdinError(error instanceof Error ? error : new Error(l10n.t('Installer input could not be written.'))); + } + } + }); +} diff --git a/src/common/localize.ts b/src/common/localize.ts index b9dd0bd9b..19a3bcd84 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -316,6 +316,61 @@ export namespace ActivationStrings { ); } +export namespace PythonInstallStrings { + export const pymanager = l10n.t('Python Install Manager'); + export const fetchingVersions = l10n.t('Finding available Python versions...'); + export const selectVersion = l10n.t('Select Python version to install'); + export const installed = l10n.t('installed'); + export const installFailed = l10n.t('Python installation did not complete. See the Python Environments output for details.'); + export const managerUnusable = l10n.t( + 'Python Install Manager is unavailable or could not be verified. Check its installation and configuration, then try again. uv was not started.', + ); + export const catalogueFailed = l10n.t( + 'Could not find Python versions using Python Install Manager. Check its configured source or network access and try again. uv was not started.', + ); + export const noCompatiblePython = l10n.t('No compatible Python was found in the selected installer\'s catalogue.'); + export const verificationFailed = l10n.t( + 'A usable Python interpreter matching the request could not be verified. See the Python Environments output.', + ); + export const discoveryFailed = l10n.t( + 'Python was installed but could not be verified. Refresh environments and try again.', + ); + export const timedOut = l10n.t( + 'Python installation timed out. Background work may still be running. No alternate installer was started; check the Python Environments output before retrying.', + ); + export const cancelled = l10n.t( + 'Python installation was canceled. Any runtime already installed is kept, and background downloads may still finish. No alternate installer was started.', + ); + export function installPrompt(version: string, requirement?: string): string { + return requirement + ? l10n.t( + '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.', + requirement, + version, + ) + : l10n.t( + '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.', + version, + ); + } + export function installAction(version: string): string { + return l10n.t('Install / Update Python {0}', version); + } + export function installing(version: string): string { + return l10n.t('Installing Python {0} using Python Install Manager...', version); + } + export function runtimeConflict(installed: string, requested: string): string { + return l10n.t( + '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.', + installed, + requested, + ); + } + export function complete(executable: string): string { + return l10n.t('Python is ready at {0}', executable); + } +} + export namespace UvInstallStrings { export const noPythonFound = l10n.t('No Python installation found'); export const installPythonPrompt = l10n.t( diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index a983a1cf7..15d719a78 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -15,6 +15,7 @@ export enum EventNames { UV_PYTHON_INSTALL_STARTED = 'UV.PYTHON_INSTALL_STARTED', UV_PYTHON_INSTALL_COMPLETED = 'UV.PYTHON_INSTALL_COMPLETED', UV_PYTHON_INSTALL_FAILED = 'UV.PYTHON_INSTALL_FAILED', + PYTHON_INSTALLER_OPERATION = 'PYTHON_INSTALLER.OPERATION', PACKAGE_MANAGEMENT = 'PACKAGE_MANAGEMENT', ADD_PROJECT = 'ADD_PROJECT', @@ -412,6 +413,23 @@ export interface IEventNamePropertyMapping { stage: 'uvInstall' | 'uvNotOnPath' | 'pythonInstall' | 'findPath'; }; + /* __GDPR__ + "python_installer.operation": { + "provider": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }, + "trigger": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }, + "phase": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }, + "reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }, + "duration": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "StellaHuang95" } + } + */ + [EventNames.PYTHON_INSTALLER_OPERATION]: { + provider: 'pymanager' | 'uv'; + trigger: 'activation' | 'createEnvironment' | 'inlineScript' | 'globalCreate'; + phase: 'prompted' | 'started' | 'completed' | 'failed' | 'cancelled' | 'declined' | 'reused'; + reason?: 'provider-unusable' | 'catalogue-failed' | 'no-compatible-python' | 'runtime-conflict' | + 'install-failed' | 'verification-failed'; + }; + /* __GDPR__ "package_management": { "managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }, diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index d2b1db5d3..13184ab18 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -220,10 +220,13 @@ export function notifyInlineScriptSetupOutcome(uri: Uri, routing: InlineScriptRo return; } if (outcome?.kind === 'cancelled') { - showInformationMessage(l10n.t('Environment setup was canceled.')); + showInformationMessage(outcome.message ?? l10n.t('Environment setup was canceled.')); return; } if (outcome?.kind === 'failed') { + if (outcome.alreadyReported) { + return; + } if (outcome.category === 'compatible-python-declined') { // User declined the install prompt; don't nag. return; @@ -232,6 +235,10 @@ export function notifyInlineScriptSetupOutcome(uri: Uri, routing: InlineScriptRo showWarningMessage(buildNoCompatiblePythonMessage(outcome.requiresPython)); return; } + if (outcome.message) { + showErrorMessage(outcome.message); + return; + } } showErrorMessage( l10n.t('Failed to set up the environment for this script. See the Python Environments output for details.'), diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index ab168e752..5acd4fa5c 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -65,7 +65,7 @@ import { selectStaleEntries, writeMetaJson, } from '../../../common/inlineScript/cacheLayout'; -import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; +import { pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata'; import { getInlineScriptMetadataRoutingIdentity, @@ -88,13 +88,13 @@ import { createDeferred, Deferred } from '../../../common/utils/deferred'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { isSameOrParentPath, normalizePath } from '../../../common/utils/pathUtils'; import { PythonVersion } from '../../../common/pythonVersion'; -import { PythonVersionSpecifier, splitClause } from '../../../common/pythonVersionSpecifier'; +import { PythonVersionSpecifier } from '../../../common/pythonVersionSpecifier'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { sortEnvironments } from '../../common/utils'; import { resolveSystemPythonEnvironmentPath } from '../utils'; -import * as uvPythonInstaller from '../uvPythonInstaller'; +import { promptInstallPythonDetailed } from '../pythonInstaller'; import { createWithProgress, hasMinimumPathDepth, isDriveRoot, resolveVenvPythonEnvironmentPath } from '../venvUtils'; import { InlineAssociationAccessor, InlineScriptAssociationStore } from './associationStore'; import { @@ -172,6 +172,9 @@ interface BuildCacheEntryResult { interface BaseInterpreterSelectionResult { readonly selectedBase?: SelectedBaseInterpreter; readonly errorCategory?: InlineScriptEnvErrorCategory; + readonly cancelled?: boolean; + readonly message?: string; + readonly alreadyReported?: boolean; } interface SelectBaseInterpreterResult { @@ -182,7 +185,13 @@ interface SelectBaseInterpreterResult { type InstallPythonAndRefreshResult = | { readonly kind: 'installed'; readonly installedPath: string } | { readonly kind: 'declined' } - | { readonly kind: 'failed' }; + | { readonly kind: 'cancelled'; readonly message?: string } + | { + readonly kind: 'failed'; + readonly category: InlineScriptEnvErrorCategory; + readonly message?: string; + readonly alreadyReported?: boolean; + }; interface DiscoveryRefreshPass { readonly promise: Promise; @@ -522,6 +531,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { operation, ); if (!baseSelection.selectedBase) { + if (baseSelection.cancelled) { + this.routingRegistry.noteSetupOutcome(scriptUri, { + kind: 'cancelled', + ...(baseSelection.message ? { message: baseSelection.message } : {}), + }); + return undefined; + } if (baseSelection.errorCategory) { this.sendInlineScriptEnvErrorTelemetry(baseSelection.errorCategory); } @@ -529,6 +545,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { kind: 'failed', category: baseSelection.errorCategory ?? 'setup-failure', requiresPython: metadata.requiresPython, + ...(baseSelection.message ? { message: baseSelection.message } : {}), + ...(baseSelection.alreadyReported ? { alreadyReported: true } : {}), }); this.log.warn( `No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`, @@ -3463,21 +3481,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const requiresPython = metadata.requiresPython?.trim() || undefined; - const lowerBound = extractLowerBoundVersion(requiresPython); - const versionSelection = await this.selectInstallablePythonVersion(requiresPython, lowerBound); - if (requiresPython && !versionSelection.version) { - this.log.warn( - 'Cannot install a Python for this inline script because no compatible install version could be selected.', - ); - return { - errorCategory: versionSelection.errorCategory ?? 'no-compatible-python', - }; - } - - const installResult = await this.installPythonAndRefresh(requiresPython, versionSelection.version); + const installResult = await this.installPythonAndRefresh(requiresPython); if (installResult.kind !== 'installed') { + if (installResult.kind === 'cancelled') { + return { cancelled: true, message: installResult.message }; + } return { - errorCategory: installResult.kind === 'declined' ? 'compatible-python-declined' : 'install-failure', + errorCategory: installResult.kind === 'declined' ? 'compatible-python-declined' : installResult.category, + ...(installResult.kind === 'failed' + ? { message: installResult.message, alreadyReported: installResult.alreadyReported } + : {}), }; } const installedPath = installResult.installedPath; @@ -3524,68 +3537,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { selectedBase: selected }; } - private async selectInstallablePythonVersion( - requiresPython: string | undefined, - lowerBound: string | undefined, - ): Promise<{ readonly version?: string; readonly errorCategory?: InlineScriptEnvErrorCategory }> { - if (!requiresPython) { - return { version: lowerBound }; - } - const prereleaseLowerBound = this.extractPrereleaseLowerBound(requiresPython); - if (prereleaseLowerBound) { - return { version: prereleaseLowerBound }; - } - const lowerBoundRelease = PythonVersion.tryParse(lowerBound); - let needsCompleteCatalog = false; - if (lowerBound && lowerBoundRelease?.major === 3) { - if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { - return { version: lowerBound }; - } - // PEP 440 `==3.13` is exact, while uv treats `3.13` as a broad minor selector. - if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { - if (lowerBoundRelease.precision >= 3) { - return { version: lowerBound }; - } - needsCompleteCatalog = true; - } - } - - let available: uvPythonInstaller.UvPythonVersion[]; - try { - const uvLookupResult = await uvPythonInstaller.ensureUvForInlineScriptVersionLookupDetailed( - requiresPython, - this.log, - ); - if (uvLookupResult !== 'available') { - return { - errorCategory: uvLookupResult === 'declined' ? 'compatible-python-declined' : 'install-failure', - }; - } - available = needsCompleteCatalog - ? await uvPythonInstaller.getAvailablePythonVersions({ allVersions: true }) - : await uvPythonInstaller.getAvailablePythonVersions(); - } catch (error) { - this.log.warn(`Unable to query Python versions available from uv: ${getErrorMessage(error)}`); - return { errorCategory: 'install-failure' }; - } - if (available.length === 0) { - return { errorCategory: 'install-failure' }; - } - const version = available - .flatMap((candidate) => { - const parsed = PythonVersion.tryParse(candidate.version); - return parsed && - candidate.implementation === 'cpython' && - candidate.variant === 'default' && - candidate.version_parts.major === 3 && - this.matchesInstallConstraint(requiresPython, candidate.version) - ? [{ parsed, raw: candidate.version }] - : []; - }) - .sort((left, right) => right.parsed.compareTo(left.parsed))[0]?.raw; - return version ? { version } : { errorCategory: 'no-compatible-python' }; - } - private matchesInstallConstraint(requiresPython: string, version: string): boolean { const candidate = PythonVersion.tryParse(version); const specifier = PythonVersionSpecifier.tryParse(requiresPython); @@ -3596,42 +3547,34 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return specifier.matches(candidate); } - private extractPrereleaseLowerBound(requiresPython: string): string | undefined { - return requiresPython - .split(',') - .map((clause) => splitClause(clause)) - .filter( - (clause) => - clause && (clause.operator === '>=' || clause.operator === '==' || clause.operator === '~='), - ) - .map((clause) => PythonVersion.tryParse(clause?.literal)) - .find((version): version is PythonVersion => !!version && version.releaseLevel !== 'final') - ?.toString(); - } - private async installPythonAndRefresh( requiresPython: string | undefined, - version: string | undefined, ): Promise { - let promptResult: uvPythonInstaller.PromptInstallPythonViaUvResult; + let installedPath: string; try { - promptResult = await uvPythonInstaller.promptInstallPythonViaUvDetailed('inlineScript', this.log, { - requiresPython, - version, - }); + const promptResult = await promptInstallPythonDetailed('inlineScript', this.log, { requiresPython }); if (promptResult.kind === 'declined') { this.log.warn( 'Python installation for inline-script environment creation was declined or did not complete.', ); return { kind: 'declined' }; } + if (promptResult.kind === 'cancelled') { + return { kind: 'cancelled', message: promptResult.message }; + } if (promptResult.kind === 'failed') { this.log.error('Failed to install Python for an inline script.'); - return { kind: 'failed' }; + return { + kind: 'failed', + category: promptResult.reason === 'no-compatible-python' ? 'no-compatible-python' : 'install-failure', + message: promptResult.message, + alreadyReported: promptResult.alreadyReported, + }; } + installedPath = promptResult.pythonPath; } catch (error) { this.log.error(`Failed to install Python for an inline script: ${getErrorMessage(error)}`); - return { kind: 'failed' }; + return { kind: 'failed', category: 'install-failure' }; } try { @@ -3641,7 +3584,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { `Python was installed for an inline script, but environment discovery could not be refreshed: ${getErrorMessage(error)}`, ); } - return { kind: 'installed', installedPath: promptResult.pythonPath }; + return { kind: 'installed', installedPath }; } /** diff --git a/src/managers/builtin/pymanagerPythonInstaller.ts b/src/managers/builtin/pymanagerPythonInstaller.ts new file mode 100644 index 000000000..1bb3e9417 --- /dev/null +++ b/src/managers/builtin/pymanagerPythonInstaller.ts @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { CancellationToken, l10n, Uri } from 'vscode'; +import which from 'which'; +import { InstallerProcessError, runInstallerProcess } from '../../common/installerProcess'; +import { traceVerbose } from '../../common/logging'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { isWindows } from '../../common/utils/platformUtils'; + +const DETECTION_BUDGET_MS = 15000; +const LOOKUP_TIMEOUT_MS = 1000; +const PROBE_TIMEOUT_MS = 3000; +const LOCAL_LIST_TIMEOUT_MS = 10000; +const CATALOGUE_TIMEOUT_MS = 60000; + +export interface PymanagerRuntime { + readonly id: string; + readonly company: string; + readonly version: string; + readonly tag: string; + readonly installTags: readonly string[]; + readonly executable: string; + readonly executableArgs: readonly string[]; + readonly prefix?: string; + readonly unmanaged: boolean; +} + +export type PymanagerDetection = + | { readonly kind: 'absent' } + | { readonly kind: 'available'; readonly executable: string } + | { readonly kind: 'unusable'; readonly executable: string; readonly error: Error }; + +export interface PymanagerListOptions { + readonly online?: boolean; + readonly selectors?: readonly string[]; + readonly onlyManaged?: boolean; + /** Cancels this native listing as part of its owning installation operation. */ + readonly cancellationToken?: CancellationToken; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function schemaError(field: string): Error { + return new Error(l10n.t('PyManager returned an invalid runtime list field: {0}.', field)); +} + +function requiredString(record: Record, field: string): string { + const value = record[field]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw schemaError(field); + } + return value; +} + +function optionalStrings(value: unknown, field: string): string[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || !value.every((item: unknown) => typeof item === 'string')) { + throw schemaError(field); + } + return [...value]; +} + +/** + * Validates PyManager's native { versions: [...] } JSON and preserves runtime metadata. + * @param output Complete JSON output from a local or online list command. + * @returns Runtime records; relative online executables remain relative. + * @throws When JSON or a required field is invalid, without echoing native output. + */ +export function parsePymanagerRuntimes(output: string): PymanagerRuntime[] { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch (_error) { + throw new Error(l10n.t('PyManager returned invalid JSON.')); + } + if (!isRecord(parsed) || !Array.isArray(parsed.versions)) { + throw schemaError('versions'); + } + const versions: unknown[] = parsed.versions; + return versions.map((value) => { + if (!isRecord(value)) { + throw schemaError('versions'); + } + if (value.prefix !== undefined && typeof value.prefix !== 'string') { + throw schemaError('prefix'); + } + if (value.unmanaged !== undefined && typeof value.unmanaged !== 'boolean') { + throw schemaError('unmanaged'); + } + return { + id: requiredString(value, 'id'), + company: requiredString(value, 'company'), + version: requiredString(value, 'sort-version'), + tag: requiredString(value, 'tag'), + installTags: optionalStrings(value['install-for'], 'install-for'), + executable: requiredString(value, 'executable'), + executableArgs: optionalStrings(value.executable_args, 'executable_args'), + prefix: value.prefix, + unmanaged: value.unmanaged ?? false, + }; + }); +} + +/** + * Lists installed or online runtimes without filtering versions or changing installations. + * @param executable An existing PyManager executable. + * @param options Native listing switches/selectors and optional cancellation for the owning operation. + * @returns All native list results, including distinct runtime families. + */ +export async function listPymanagerRuntimes( + executable: string, + options: PymanagerListOptions = {}, +): Promise { + const args = [ + 'list', + ...(options.online ? ['--online'] : []), + ...(options.onlyManaged ? ['--only-managed'] : []), + '--format=json', + '-q', + ...(options.selectors ?? []), + ]; + const result = await runInstallerProcess(executable, args, { + timeoutMs: options.online ? CATALOGUE_TIMEOUT_MS : LOCAL_LIST_TIMEOUT_MS, + ...(options.cancellationToken ? { cancellationToken: options.cancellationToken } : {}), + }); + return parsePymanagerRuntimes(result.stdout); +} + +function comparisonKey(value: string): string { + return normalizePath(Uri.file(path.resolve(value)).fsPath); +} + +function candidatePaths(): string[] { + const candidates: string[] = []; + const seen = new Set(); + const cwd = comparisonKey(process.cwd()); + const add = (directory: string | undefined, ...parts: string[]): void => { + if (!directory || !path.isAbsolute(directory)) { + return; + } + const executable = path.resolve(path.join(directory, ...parts, 'pymanager.exe')); + const key = comparisonKey(executable); + if (comparisonKey(path.dirname(executable)) !== cwd && !seen.has(key)) { + seen.add(key); + candidates.push(executable); + } + }; + for (const directory of (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter)) { + add(directory.replace(/^"(.*)"$/, '$1')); + } + for (const family of [ + 'PythonSoftwareFoundation.PythonManager_3847v3x7pw1km', + 'PythonSoftwareFoundation.PythonManager_qbz5n2kfra8p0', + ]) { + add(process.env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', family); + } + add(process.env.ProgramFiles, 'PyManager'); + return candidates; +} + +async function isCandidate(executable: string): Promise { + try { + const stat = await fs.lstat(executable); + if (stat.isSymbolicLink()) { + // MSIX App Execution Aliases are executable reparse points whose + // targets cannot be opened with stat/which. Probe the public alias itself. + return true; + } + if (!stat.isFile()) { + throw new Error(l10n.t('The PyManager executable is not a file.')); + } + } catch (error) { + if (isFileNotFoundError(error) || (isRecord(error) && error.code === 'ENOTDIR')) { + return false; + } + throw error; + } + // which adds cwd on Windows for bare commands. Absolute candidates avoid + // that search and restrict resolution to .exe, regardless of PATHEXT. + const resolved = await which(executable, { nothrow: true, pathExt: '.EXE' }); + if (!resolved || comparisonKey(resolved) !== comparisonKey(executable)) { + throw new Error(l10n.t('The PyManager executable could not be resolved.')); + } + return true; +} + +async function boundedLookup(executable: string, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + isCandidate(executable), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new InstallerProcessError('timeout')), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +async function detectWindowsPymanager(): Promise { + const deadline = Date.now() + DETECTION_BUDGET_MS; + let unusable: Extract | undefined; + for (const executable of candidatePaths()) { + try { + if (Date.now() >= deadline) { + throw new InstallerProcessError('timeout'); + } + if (!(await boundedLookup(executable, Math.min(LOOKUP_TIMEOUT_MS, deadline - Date.now())))) { + continue; + } + if (Date.now() >= deadline) { + throw new InstallerProcessError('timeout'); + } + const result = await runInstallerProcess( + executable, + ['list', '--only-managed', '--one', '--format=json', '-q'], + { timeoutMs: Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()) }, + ); + parsePymanagerRuntimes(result.stdout); + return { kind: 'available', executable }; + } catch (error) { + traceVerbose('A PyManager candidate could not be queried.'); + unusable ??= { + kind: 'unusable', + executable, + error: error instanceof Error ? error : new Error(l10n.t('PyManager could not be queried.')), + }; + if (Date.now() >= deadline) { + break; + } + } + } + return unusable ?? { kind: 'absent' }; +} + +let detectionInFlight: Promise | undefined; + +/** + * Detects an existing, usable Windows PyManager without bootstrapping or configuring it. + * @returns Absent, available (including zero installed runtimes), or a candidate's failure. + * Concurrent calls share one bounded probe; subsequent calls retry instead of caching absence. + */ +export async function detectPymanager(): Promise { + if (!isWindows()) { + return { kind: 'absent' }; + } + detectionInFlight ??= detectWindowsPymanager().finally(() => { + detectionInFlight = undefined; + }); + return detectionInFlight; +} diff --git a/src/managers/builtin/pythonInstaller.ts b/src/managers/builtin/pythonInstaller.ts new file mode 100644 index 000000000..65f3e6caa --- /dev/null +++ b/src/managers/builtin/pythonInstaller.ts @@ -0,0 +1,584 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fs from 'fs-extra'; +import { promises as nativeFs } from 'fs'; +import * as path from 'path'; +import { CancellationError, CancellationToken, LogOutputChannel, ProgressLocation, QuickPickItem } from 'vscode'; +import { Common, PythonInstallStrings } from '../../common/localize'; +import { InstallerProcessError, runInstallerProcess } from '../../common/installerProcess'; +import { traceError, traceInfo, traceWarn } from '../../common/logging'; +import { PythonVersion } from '../../common/pythonVersion'; +import { EventNames } from '../../common/telemetry/constants'; +import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { isWindows } from '../../common/utils/platformUtils'; +import { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis'; +import { getGlobalPersistentState } from '../../common/persistentState'; +import { extractLowerBoundVersion } from '../../common/inlineScript/interpreter'; +import { PythonVersionSpecifier, splitClause } from '../../common/pythonVersionSpecifier'; +import { detectPymanager, listPymanagerRuntimes, PymanagerRuntime } from './pymanagerPythonInstaller'; +import { + getPymanagerCandidates, + getPymanagerRuntimeVersion, + matchesPythonInstallRequest, + PymanagerCandidate, + resolvePymanagerCandidate, + validatePythonInstallRequest, +} from './pythonRuntimeCandidates'; +import { + PythonInstallationError, + PythonInstaller, + PythonInstallFailure, + PythonInstallRequest, + PythonInstallResult, + PythonInstallTrigger, +} from './pythonInstallerTypes'; +import * as uvInstaller from './uvPythonInstaller'; +import { withPackageWatchersPaused } from '../common/packageWatcher'; + +type InstallTelemetryTrigger = PythonInstallTrigger | 'globalCreate'; +type InstallPhase = 'prompted' | 'started' | 'completed' | 'failed' | 'cancelled' | 'declined' | 'reused'; + +const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; +const INTERPRETER_PROBE_TIMEOUT_MS = 30_000; +const INTERPRETER_PROBE = + 'import json, platform, sys; ' + + 'print(json.dumps({"executable":sys.executable,"prefix":sys.prefix,"basePrefix":sys.base_prefix,' + + '"version":platform.python_version(),"implementation":platform.python_implementation()}))'; + +let pymanagerOperations: Promise = Promise.resolve(); +let activePymanager: Extract | undefined; +let pendingPymanagerOperations = 0; + +interface VerifiedPython { + readonly executable: string; + readonly version: PythonVersion; +} + +interface RuntimeQuickPickItem extends QuickPickItem { + readonly candidate: PymanagerCandidate; +} + +function enqueuePymanagerOperation( + installer: Extract, + operation: () => Promise, +): Promise { + activePymanager ??= installer; + pendingPymanagerOperations += 1; + const next = pymanagerOperations.then(operation); + pymanagerOperations = next.then(() => undefined, () => undefined); + return next.finally(() => { + if (--pendingPymanagerOperations === 0) { + activePymanager = undefined; + } + }); +} + +/** Choose a runtime installer without querying the environment API or installing an installer. */ +export async function getPythonInstaller(): Promise { + if (!isWindows()) { + return { kind: 'uv' }; + } + // A probe would wait behind our own native PyManager operation. Queue the + // caller instead of misreporting a busy, already verified manager as unusable. + if (activePymanager) { + return activePymanager; + } + const detected = await detectPymanager(); + if (detected.kind === 'absent') { + return { kind: 'uv' }; + } + if (detected.kind === 'unusable') { + traceWarn('Python Install Manager is present but unavailable:', detected.error); + throw new PythonInstallationError('provider-unusable', PythonInstallStrings.managerUnusable); + } + return { kind: 'pymanager', executable: detected.executable }; +} + +function report( + provider: PythonInstaller['kind'], + trigger: InstallTelemetryTrigger, + phase: InstallPhase, + reason?: PythonInstallFailure, + duration?: number, +): void { + sendTelemetryEvent(EventNames.PYTHON_INSTALLER_OPERATION, duration, { provider, trigger, phase, reason }); +} + +function failureMessage(reason: PythonInstallFailure): string { + switch (reason) { + case 'provider-unusable': + return PythonInstallStrings.managerUnusable; + case 'catalogue-failed': + return PythonInstallStrings.catalogueFailed; + case 'no-compatible-python': + return PythonInstallStrings.noCompatiblePython; + case 'verification-failed': + return PythonInstallStrings.verificationFailed; + default: + return PythonInstallStrings.installFailed; + } +} + +function failedResult(error: unknown, trigger: InstallTelemetryTrigger): PythonInstallResult { + if (error instanceof CancellationError) { + if (trigger !== 'inlineScript') { + void showInformationMessage(PythonInstallStrings.cancelled); + } + return { kind: 'cancelled', message: PythonInstallStrings.cancelled }; + } + const reason = error instanceof PythonInstallationError ? error.reason : 'install-failed'; + const message = error instanceof InstallerProcessError && error.kind === 'timeout' + ? PythonInstallStrings.timedOut + : reason === 'runtime-conflict' && error instanceof PythonInstallationError + ? error.message + : failureMessage(reason); + traceError(`Python runtime installation failed (${reason}):`, error); + if (trigger !== 'inlineScript') { + void showErrorMessage(message); + } + return { + kind: 'failed', + reason, + ...(reason !== 'no-compatible-python' ? { message } : {}), + alreadyReported: trigger !== 'inlineScript', + }; +} + +async function readPymanagerInstalled( + installer: Extract, + cancellationToken?: CancellationToken, +): Promise { + if (cancellationToken?.isCancellationRequested) { + throw new CancellationError(); + } + try { + return await listPymanagerRuntimes(installer.executable, { + onlyManaged: true, + ...(cancellationToken ? { cancellationToken } : {}), + }); + } catch (error) { + if (error instanceof CancellationError) { + throw error; + } + traceWarn('Unable to inspect Python Install Manager runtime state:', error); + throw new PythonInstallationError('provider-unusable', PythonInstallStrings.managerUnusable); + } +} + +async function queryPymanagerCatalogue(query: () => PromiseLike): Promise { + try { + return await query(); + } catch (error) { + if (error instanceof PythonInstallationError || error instanceof CancellationError) { + throw error; + } + traceWarn('PyManager catalogue lookup failed:', error); + throw new PythonInstallationError('catalogue-failed', PythonInstallStrings.catalogueFailed); + } +} + +/** + * Verify the actual base interpreter rather than treating catalogue metadata, a launcher, + * or a successful installer exit code as sufficient evidence. + */ +export async function verifyPymanagerRuntime( + runtime: PymanagerRuntime, + request: PythonInstallRequest, + cancellationToken?: CancellationToken, +): Promise { + try { + return await verifyPymanagerRuntimeCore(runtime, request, cancellationToken); + } catch (error) { + if (error instanceof CancellationError || error instanceof PythonInstallationError) { + throw error; + } + traceWarn('The installed Python could not be verified:', error); + throw new PythonInstallationError('verification-failed', PythonInstallStrings.verificationFailed); + } +} + +async function verifyPymanagerRuntimeCore( + runtime: PymanagerRuntime, + request: PythonInstallRequest, + cancellationToken?: CancellationToken, +): Promise { + const expectedVersion = getPymanagerRuntimeVersion(runtime); + if ( + runtime.unmanaged || + !expectedVersion || + !runtime.prefix || + !path.isAbsolute(runtime.executable) || + !path.isAbsolute(runtime.prefix) || + !matchesPythonInstallRequest(expectedVersion, request) || + !(await fs.stat(runtime.executable)).isFile() || + await fs.pathExists(path.join(runtime.prefix, 'pyvenv.cfg')) + ) { + throw new PythonInstallationError('verification-failed', 'The reported PyManager runtime is not a usable matching base Python.'); + } + const output = await runInstallerProcess( + runtime.executable, + ['-I', '-S', '-c', INTERPRETER_PROBE], + { timeoutMs: INTERPRETER_PROBE_TIMEOUT_MS, cancellationToken }, + ); + let parsed: unknown; + try { + parsed = JSON.parse(output.stdout); + } catch { + throw new PythonInstallationError('verification-failed', 'The installed Python did not return interpreter metadata.'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new PythonInstallationError('verification-failed', 'Invalid installed Python metadata.'); + } + const info = parsed as Record; + const actualVersion = PythonVersion.tryParse(info.version); + if ( + typeof info.executable !== 'string' || + typeof info.prefix !== 'string' || + typeof info.basePrefix !== 'string' || + !path.isAbsolute(info.executable) || + !path.isAbsolute(info.prefix) || + !path.isAbsolute(info.basePrefix) || + info.implementation !== 'CPython' || + !actualVersion || + actualVersion.compareTo(expectedVersion) !== 0 || + !matchesPythonInstallRequest(actualVersion, request) + ) { + throw new PythonInstallationError('verification-failed', 'The installed Python does not match its requested runtime.'); + } + const [expectedExecutable, actualExecutable, expectedPrefix, actualPrefix, basePrefix] = await Promise.all([ + nativeFs.realpath(runtime.executable), + nativeFs.realpath(info.executable), + nativeFs.realpath(runtime.prefix), + nativeFs.realpath(info.prefix), + nativeFs.realpath(info.basePrefix), + ]); + if ( + normalizePath(expectedExecutable) !== normalizePath(actualExecutable) || + normalizePath(expectedPrefix) !== normalizePath(actualPrefix) || + normalizePath(actualPrefix) !== normalizePath(basePrefix) + ) { + throw new PythonInstallationError('verification-failed', 'The installed Python resolved outside its reported base installation.'); + } + return { executable: actualExecutable, version: actualVersion }; +} + +async function reusePymanagerRuntime( + runtimes: readonly PymanagerRuntime[], + request: PythonInstallRequest, + trigger: InstallTelemetryTrigger, +): Promise { + const compatible = runtimes.flatMap((runtime) => { + const version = getPymanagerRuntimeVersion(runtime); + return version && matchesPythonInstallRequest(version, request) ? [{ runtime, version }] : []; + }).sort((a, b) => b.version.compareTo(a.version)); + let validationError: unknown; + for (const { runtime } of compatible) { + try { + const verified = await verifyPymanagerRuntime(runtime, request); + report('pymanager', trigger, 'reused'); + return { kind: 'installed', provider: 'pymanager', pythonPath: verified.executable }; + } catch (error) { + validationError = error; + traceWarn('A matching Python Install Manager runtime could not be verified:', error); + } + } + if (validationError) { + throw new PythonInstallationError('verification-failed', PythonInstallStrings.verificationFailed); + } + return undefined; +} + +function checkPymanagerSlot( + candidate: PymanagerCandidate, + installed: readonly PymanagerRuntime[], + request: PythonInstallRequest, +): void { + const current = installed.find((runtime) => runtime.id.toLowerCase() === candidate.runtime.id.toLowerCase()); + if (!current) { + return; + } + const version = PythonVersion.tryParse(current.version); + if (!version) { + throw new PythonInstallationError('verification-failed', 'Cannot verify the existing PyManager installation slot.'); + } + if (version.compareTo(candidate.version) > 0 && !matchesPythonInstallRequest(version, request)) { + throw new PythonInstallationError( + 'runtime-conflict', + PythonInstallStrings.runtimeConflict(version.toString(), candidate.version.toString()), + ); + } +} + +/** + * Install one approved runtime slot. The caller must obtain informed consent to install or + * update older runtimes in this family. An explicit tag plus --update cannot downgrade a + * newer runtime which appeared after preflight; post-verification still enforces the request. + */ +async function installApprovedPymanagerRuntime( + installer: Extract, + candidate: PymanagerCandidate, + request: PythonInstallRequest, + log?: LogOutputChannel, + cancellationToken?: CancellationToken, +): Promise { + const installed = await readPymanagerInstalled(installer, cancellationToken); + checkPymanagerSlot(candidate, installed, request); + const current = installed.find((runtime) => runtime.id.toLowerCase() === candidate.runtime.id.toLowerCase()); + const operation = () => installAndVerifyPymanagerRuntime(installer, candidate, request, log, cancellationToken); + return current?.prefix + ? withPackageWatchersPaused(await nativeFs.realpath(current.prefix), operation) + : operation(); +} + +async function installAndVerifyPymanagerRuntime( + installer: Extract, + candidate: PymanagerCandidate, + request: PythonInstallRequest, + log?: LogOutputChannel, + cancellationToken?: CancellationToken, +): Promise { + await runInstallerProcess( + installer.executable, + ['install', '--update', '--yes', candidate.installTag], + { + timeoutMs: INSTALL_TIMEOUT_MS, + cancellationToken, + onOutput: (text) => log?.append(text), + }, + ); + const refreshed = await readPymanagerInstalled(installer, cancellationToken); + const runtime = refreshed.find((item) => item.id.toLowerCase() === candidate.runtime.id.toLowerCase()); + if (!runtime) { + throw new PythonInstallationError('verification-failed', 'PyManager did not report the installed runtime.'); + } + checkPymanagerSlot(candidate, refreshed, request); + return (await verifyPymanagerRuntime(runtime, request, cancellationToken)).executable; +} + +async function promptPymanagerInstallation( + installer: Extract, + request: PythonInstallRequest, + trigger: InstallTelemetryTrigger, + log?: LogOutputChannel, + selectedCandidate?: PymanagerCandidate, +): Promise { + return enqueuePymanagerOperation(installer, async () => { + const installed = await readPymanagerInstalled(installer); + const reused = await reusePymanagerRuntime(installed, request, trigger); + if (reused) { + return reused; + } + let candidate = selectedCandidate; + if (!candidate) { + candidate = await queryPymanagerCatalogue( + () => withProgress( + { location: ProgressLocation.Notification, title: PythonInstallStrings.fetchingVersions }, + () => resolvePymanagerCandidate(installer.executable, request), + ), + ); + } + if (!candidate) { + throw new PythonInstallationError('no-compatible-python', PythonInstallStrings.noCompatiblePython); + } + checkPymanagerSlot(candidate, installed, request); + const version = candidate.version.toString(); + const action = PythonInstallStrings.installAction(version); + const message = PythonInstallStrings.installPrompt( + version, + uvInstaller.sanitizePromptDetail(request.requiresPython), + ); + report('pymanager', trigger, 'prompted'); + const choice = trigger === 'activation' || trigger === 'createEnvironment' + ? await showInformationMessage(message, { modal: true }, action, Common.dontAskAgain) + : await showInformationMessage(message, { modal: true }, action); + if (choice === Common.dontAskAgain) { + const state = await getGlobalPersistentState(); + await state.set(uvInstaller.UV_INSTALL_PYTHON_DONT_ASK_KEY, true); + } + if (choice !== action) { + report('pymanager', trigger, 'declined'); + return { kind: 'declined' }; + } + const approvedCandidate = candidate; + const started = Date.now(); + report('pymanager', trigger, 'started'); + try { + const pythonPath = await withProgress( + { + location: ProgressLocation.Notification, + title: PythonInstallStrings.installing(version), + cancellable: true, + }, + (_progress, token) => installApprovedPymanagerRuntime( + installer, approvedCandidate, request, log, token, + ), + ); + report('pymanager', trigger, 'completed', undefined, Date.now() - started); + traceInfo(`Python Install Manager supplied a verified interpreter at ${pythonPath}`); + if (trigger !== 'inlineScript') { + void showInformationMessage(PythonInstallStrings.complete(pythonPath)); + } + return { kind: 'installed', provider: 'pymanager', pythonPath }; + } catch (error) { + const phase = error instanceof CancellationError ? 'cancelled' : 'failed'; + report('pymanager', trigger, phase, error instanceof PythonInstallationError ? error.reason : 'install-failed', + Date.now() - started); + throw error; + } + }); +} + +type UvVersionResolution = + | { readonly kind: 'selected'; readonly version?: string } + | { readonly kind: 'declined' } + | { readonly kind: 'failed'; readonly reason: PythonInstallFailure }; + +async function resolveUvVersion(request: PythonInstallRequest, log?: LogOutputChannel): Promise { + if (request.version !== undefined || !request.requiresPython) { + return { kind: 'selected', version: request.version }; + } + const requiresPython = request.requiresPython; + const lowerBound = extractLowerBoundVersion(requiresPython); + const prerelease = requiresPython.split(',').map(splitClause) + .filter((clause) => clause && ['>=', '==', '~='].includes(clause.operator)) + .map((clause) => PythonVersion.tryParse(clause?.literal)) + .find((version) => version && version.releaseLevel !== 'final'); + if (prerelease) { + return { kind: 'selected', version: prerelease.toString() }; + } + const version = PythonVersion.tryParse(lowerBound); + const specifier = PythonVersionSpecifier.tryParse(requiresPython); + let allVersions = false; + if (lowerBound && version?.major === 3 && specifier?.matches(version)) { + if (/^>=\s*[^,]+$/.test(requiresPython)) { + return { kind: 'selected', version: lowerBound }; + } + if (/^==\s*[^,*]+$/.test(requiresPython)) { + if (version.precision >= 3) { + return { kind: 'selected', version: lowerBound }; + } + allVersions = true; + } + } + const available = await uvInstaller.ensureUvForInlineScriptVersionLookupDetailed(requiresPython, log); + if (available !== 'available') { + return available === 'declined' ? { kind: 'declined' } : { kind: 'failed', reason: 'install-failed' }; + } + const catalogue = allVersions + ? await uvInstaller.getAvailablePythonVersions({ allVersions: true }) + : await uvInstaller.getAvailablePythonVersions(); + if (catalogue.length === 0) { + return { kind: 'failed', reason: 'catalogue-failed' }; + } + const best = catalogue.flatMap((item) => { + const parsed = PythonVersion.tryParse(item.version); + return parsed && item.implementation === 'cpython' && item.variant === 'default' && + matchesPythonInstallRequest(parsed, request) ? [{ parsed, version: item.version }] : []; + }).sort((a, b) => b.parsed.compareTo(a.parsed))[0]; + return best ? { kind: 'selected', version: best.version } : { kind: 'failed', reason: 'no-compatible-python' }; +} + +/** + * Acquire a base Python without querying global environment enumeration. Callers remain + * responsible for PET resolution, selection, settings, and creating their virtual environment. + */ +export async function promptInstallPythonDetailed( + trigger: PythonInstallTrigger, + log?: LogOutputChannel, + request: PythonInstallRequest = {}, +): Promise { + try { + validatePythonInstallRequest(request); + if (trigger !== 'inlineScript' && await uvInstaller.isDontAskAgainSet()) { + return { kind: 'declined' }; + } + const installer = await getPythonInstaller(); + if (installer.kind === 'pymanager') { + return await promptPymanagerInstallation(installer, request, trigger, log); + } + const selection = trigger === 'inlineScript' + ? await resolveUvVersion(request, log) + : { kind: 'selected' as const, version: request.version }; + if (selection.kind !== 'selected') { + return selection; + } + const result = await uvInstaller.promptInstallPythonViaUvDetailed(trigger, log, { + requiresPython: request.requiresPython, + version: selection.version, + }); + report('uv', trigger, result.kind === 'installed' ? 'completed' : result.kind === 'declined' ? 'declined' : 'failed'); + return result.kind === 'installed' + ? { ...result, provider: 'uv' } + : result.kind === 'failed' + ? { kind: 'failed', reason: 'install-failed' } + : result; + } catch (error) { + if (error instanceof InstallerProcessError && error.kind === 'timeout') { + traceWarn('Python installer timed out; no alternate installer will be started.', error); + } + return failedResult(error, trigger); + } +} + +/** Compatibility convenience for the ordinary missing-Python flows. */ +export async function promptInstallPython( + trigger: PythonInstallTrigger, + log?: LogOutputChannel, + request: PythonInstallRequest = {}, +): Promise { + const result = await promptInstallPythonDetailed(trigger, log, request); + return result.kind === 'installed' ? result.pythonPath : undefined; +} + +/** Pick a global Python using the chosen provider's catalogue, then acquire that exact release. */ +export async function selectAndInstallPython(log?: LogOutputChannel): Promise { + try { + const installer = await getPythonInstaller(); + if (installer.kind === 'uv') { + const selected = await uvInstaller.selectPythonVersionToInstall(log); + return selected ? uvInstaller.installPythonWithUv(log, selected) : undefined; + } + const { runtimes, installed } = await enqueuePymanagerOperation( + installer, + async () => withProgress( + { location: ProgressLocation.Notification, title: PythonInstallStrings.fetchingVersions }, + async () => { + // PyManager serializes native commands. Do not let a short local + // lookup time out behind our own catalogue query or installation. + const installed = await readPymanagerInstalled(installer); + const runtimes = await queryPymanagerCatalogue( + () => listPymanagerRuntimes(installer.executable, { online: true }), + ); + return { runtimes, installed }; + }, + ), + ); + const candidates = getPymanagerCandidates(runtimes); + if (candidates.length === 0) { + throw new PythonInstallationError('no-compatible-python', PythonInstallStrings.noCompatiblePython); + } + const items: RuntimeQuickPickItem[] = candidates.map((candidate) => { + const current = installed.find((runtime) => runtime.id.toLowerCase() === candidate.runtime.id.toLowerCase() && + PythonVersion.tryParse(runtime.version)?.compareTo(candidate.version) === 0); + return { + label: `Python ${candidate.version.toString()}`, + description: current ? PythonInstallStrings.installed : PythonInstallStrings.pymanager, + detail: current?.executable, + candidate, + }; + }); + const selected = await showQuickPick(items, { placeHolder: PythonInstallStrings.selectVersion, ignoreFocusOut: true }); + if (!selected) { + return undefined; + } + const result = await promptPymanagerInstallation( + installer, { version: selected.candidate.version.toString() }, 'globalCreate', log, selected.candidate, + ); + return result.kind === 'installed' ? result.pythonPath : undefined; + } catch (error) { + failedResult(error, 'globalCreate'); + return undefined; + } +} diff --git a/src/managers/builtin/pythonInstallerTypes.ts b/src/managers/builtin/pythonInstallerTypes.ts new file mode 100644 index 000000000..b1f4d2757 --- /dev/null +++ b/src/managers/builtin/pythonInstallerTypes.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** The runtime installer, distinct from an environment or package manager. */ +export type PythonInstaller = + | { readonly kind: 'uv' } + | { readonly kind: 'pymanager'; readonly executable: string }; + +export type PythonInstallTrigger = 'activation' | 'createEnvironment' | 'inlineScript'; + +/** + * A version selector and a compatibility constraint have different semantics: + * version "3.13" selects a patch family, whereas requiresPython "==3.13" selects 3.13.0. + */ +export interface PythonInstallRequest { + readonly version?: string; + readonly requiresPython?: string; +} + +export type PythonInstallFailure = + | 'provider-unusable' + | 'catalogue-failed' + | 'no-compatible-python' + | 'runtime-conflict' + | 'install-failed' + | 'verification-failed'; + +export type PythonInstallResult = + | { + readonly kind: 'installed'; + readonly pythonPath: string; + readonly provider: PythonInstaller['kind']; + } + | { readonly kind: 'declined' } + | { readonly kind: 'cancelled'; readonly message?: string } + | { + readonly kind: 'failed'; + readonly reason: PythonInstallFailure; + readonly message?: string; + readonly alreadyReported?: boolean; + }; + +/** An internal classified failure; callers supply the localized notification. */ +export class PythonInstallationError extends Error { + constructor( + public readonly reason: PythonInstallFailure, + message: string, + ) { + super(message); + this.name = 'PythonInstallationError'; + } +} diff --git a/src/managers/builtin/pythonRuntimeCandidates.ts b/src/managers/builtin/pythonRuntimeCandidates.ts new file mode 100644 index 000000000..4942dc8f2 --- /dev/null +++ b/src/managers/builtin/pythonRuntimeCandidates.ts @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { PythonVersion } from '../../common/pythonVersion'; +import { PythonVersionSpecifier, splitClause } from '../../common/pythonVersionSpecifier'; +import { extractLowerBoundVersion } from '../../common/inlineScript/interpreter'; +import { PythonInstallationError, PythonInstallRequest } from './pythonInstallerTypes'; +import { listPymanagerRuntimes, PymanagerRuntime } from './pymanagerPythonInstaller'; + +export type PythonRuntimeArchitecture = 'x64' | 'arm64' | 'ia32'; + +export interface PymanagerCandidate { + readonly runtime: PymanagerRuntime; + readonly version: PythonVersion; + readonly architecture: PythonRuntimeArchitecture; + readonly installTag: string; +} + +const ARCHITECTURE_SUFFIXES: Readonly> = { + x64: '64', + arm64: 'arm64', + ia32: '32', +}; +const MAX_RELEASE_COMPONENT = 1000; +const MAX_HISTORY_QUERIES = 64; +const QUERY_BATCH_SIZE = 64; +const HISTORY_TIMEOUT_MS = 120_000; + +/** Return the architecture of the extension host, not the PyManager executable or desktop client. */ +export function getPythonRuntimeArchitecture(): PythonRuntimeArchitecture { + switch (process.arch) { + case 'x64': + case 'arm64': + case 'ia32': + return process.arch; + default: + throw new PythonInstallationError('provider-unusable', `Unsupported Python runtime architecture: ${process.arch}`); + } +} + +/** Validate the request before querying a catalogue or prompting for installation. */ +export function validatePythonInstallRequest(request: PythonInstallRequest): void { + if (request.version !== undefined && !PythonVersion.tryParse(request.version)) { + throw new PythonInstallationError('no-compatible-python', 'Invalid Python version selector.'); + } + if (request.requiresPython !== undefined && !PythonVersionSpecifier.tryParse(request.requiresPython)) { + throw new PythonInstallationError('no-compatible-python', 'Invalid requires-python constraint.'); + } +} + +/** Test an actual interpreter release, never a PyManager slot ID or tag, against the request. */ +export function matchesPythonInstallRequest(version: PythonVersion, request: PythonInstallRequest): boolean { + const selector = request.version === undefined ? undefined : PythonVersion.tryParse(request.version); + const specifier = + request.requiresPython === undefined ? undefined : PythonVersionSpecifier.tryParse(request.requiresPython); + if ( + version.major !== 3 || + (request.version !== undefined && !selector) || + (request.requiresPython !== undefined && !specifier) || + (selector && !version.matchesSelector(selector)) || + (specifier && !specifier.matches(version)) + ) { + return false; + } + return ( + version.releaseLevel === 'final' || + specifier !== undefined || + (selector !== undefined && selector.releaseLevel !== 'final') + ); +} + +/** + * Prefer the existing inline-script download choices. A derived preference is not an + * additional constraint: an unavailable lower bound may fall back to another compatible release. + */ +export function getPreferredPythonSelector(requiresPython: string | undefined): string | undefined { + requiresPython = requiresPython?.trim(); + if (!requiresPython) { + return undefined; + } + const specifier = PythonVersionSpecifier.tryParse(requiresPython); + if (!specifier) { + return undefined; + } + for (const text of requiresPython.split(',')) { + const clause = splitClause(text); + if (clause && ['>=', '==', '~='].includes(clause.operator)) { + const version = PythonVersion.tryParse(clause.literal); + if (version && version.releaseLevel !== 'final' && specifier.matches(version)) { + return version.toString(); + } + } + } + const lowerBound = extractLowerBoundVersion(requiresPython); + const version = PythonVersion.tryParse(lowerBound); + if (!version || version.major !== 3 || !specifier.matches(version)) { + return undefined; + } + if (/^>=\s*[^,]+$/.test(requiresPython)) { + return lowerBound; + } + if (/^==\s*[^,*]+$/.test(requiresPython)) { + return version.toString(); + } + return undefined; +} + +/** Identify a standard CPython runtime without mistaking a tag or an active venv for a base version. */ +export function getPymanagerRuntimeVersion( + runtime: PymanagerRuntime, + architecture: PythonRuntimeArchitecture = getPythonRuntimeArchitecture(), +): PythonVersion | undefined { + const version = PythonVersion.tryParse(runtime.version); + if ( + runtime.company.toLowerCase() !== 'pythoncore' || + !version || + version.major !== 3 || + runtime.executableArgs.length > 0 || + !/^\d+\.\d+(?:\.\d+(?:(?:a|b|rc)\d+)?)?(?:-dev)?-(?:64|32|arm64)$/i.test(runtime.tag) + ) { + return undefined; + } + const suffix = ARCHITECTURE_SUFFIXES[architecture]; + if (!runtime.tag.toLowerCase().endsWith(`-${suffix}`)) { + return undefined; + } + return version; +} + +/** Normalize only standard CPython builds with a concrete, advertised installation tag. */ +export function toPymanagerCandidate( + runtime: PymanagerRuntime, + architecture: PythonRuntimeArchitecture = getPythonRuntimeArchitecture(), +): PymanagerCandidate | undefined { + const version = getPymanagerRuntimeVersion(runtime, architecture); + if (!version) { + return undefined; + } + const suffix = ARCHITECTURE_SUFFIXES[architecture]; + const concrete = `${version.toString()}-${suffix}`; + const tag = runtime.installTags.find((item) => item.toLowerCase() === concrete.toLowerCase()); + if (!tag) { + return undefined; + } + return { + runtime, + version, + architecture, + installTag: `PythonCore\\${tag}`, + }; +} + +/** Normalize and order eligible PyManager catalogue records without assuming their input order. */ +export function getPymanagerCandidates( + runtimes: readonly PymanagerRuntime[], + architecture: PythonRuntimeArchitecture = getPythonRuntimeArchitecture(), +): PymanagerCandidate[] { + return runtimes + .map((runtime) => toPymanagerCandidate(runtime, architecture)) + .filter((candidate): candidate is PymanagerCandidate => candidate !== undefined) + .sort((a, b) => b.version.compareTo(a.version)); +} + +/** + * Resolve a compatible published runtime through PyManager's configured catalogue. + * + * Its list command deduplicates slots, and its range filters compare tags rather than actual + * versions. Historical searches therefore submit bounded batches of exact tags which satisfy + * our version predicate; only returned, independently validated catalogue records may be installed. + */ +export async function resolvePymanagerCandidate( + executable: string, + request: PythonInstallRequest, + architecture: PythonRuntimeArchitecture = getPythonRuntimeArchitecture(), +): Promise { + validatePythonInstallRequest(request); + const suffix = ARCHITECTURE_SUFFIXES[architecture]; + const selector = PythonVersion.tryParse(request.version); + const preference = request.version === undefined + ? PythonVersion.tryParse(getPreferredPythonSelector(request.requiresPython)) + : undefined; + const specifier = request.requiresPython ? PythonVersionSpecifier.tryParse(request.requiresPython) : undefined; + let queries = 0; + const started = Date.now(); + + const query = async (selectors: readonly string[]): Promise => { + if (++queries > MAX_HISTORY_QUERIES || Date.now() - started > HISTORY_TIMEOUT_MS) { + throw new PythonInstallationError( + 'catalogue-failed', + 'The historical Python catalogue lookup exceeded its bounded search budget.', + ); + } + return getPymanagerCandidates( + await listPymanagerRuntimes(executable, { online: true, selectors }), + architecture, + ); + }; + const compatible = (candidate: PymanagerCandidate) => matchesPythonInstallRequest(candidate.version, request); + + if (selector && (selector.precision === 3 || selector.releaseLevel !== 'final')) { + const candidates = await query([`PythonCore\\${selector.toString()}-${suffix}`]); + return candidates.find(compatible); + } + + if (preference) { + const tag = preference.releaseLevel === 'final' ? preference.toReleaseString() : preference.toString(); + const candidates = await query([`PythonCore\\${tag}-${suffix}`]); + const preferred = candidates.find((candidate) => + candidate.version.matchesSelector(preference) && compatible(candidate)); + if (preferred) { + return preferred; + } + } + + const initialSelectors = selector + ? [`PythonCore\\${selector.toReleaseString()}-${suffix}`] + : []; + const catalogue = await query(initialSelectors); + const families = new Map(); + for (const candidate of catalogue) { + const key = `${candidate.version.major}.${candidate.version.minor}`; + const previous = families.get(key); + if (!previous || previous.version.compareTo(candidate.version) < 0) { + families.set(key, candidate); + } + } + const allowsPrereleases = + (selector?.releaseLevel !== undefined && selector.releaseLevel !== 'final') || + (request.requiresPython?.split(',').some((clause) => { + const parts = splitClause(clause); + const bound = PythonVersion.tryParse(parts?.literal); + return bound !== undefined && bound.releaseLevel !== 'final'; + }) ?? false); + const clauseVersions = request.requiresPython?.split(',').flatMap((text) => { + const clause = splitClause(text); + const version = PythonVersion.tryParse(clause?.literal); + return version ? [version] : []; + }) ?? []; + + for (const latest of families.values()) { + if (compatible(latest)) { + return latest; + } + if (latest.version.patch > MAX_RELEASE_COMPONENT) { + throw new PythonInstallationError('catalogue-failed', 'The Python catalogue has an unsupported release range.'); + } + const release = (patch: number) => `${latest.version.major}.${latest.version.minor}.${patch}`; + const matches = (version: PythonVersion) => + version.compareTo(latest.version) <= 0 && matchesPythonInstallRequest(version, request); + const finalTags: string[] = []; + for (let patch = latest.version.patch; patch >= 0; patch -= 1) { + const version = new PythonVersion(release(patch)); + if (matches(version)) { + finalTags.push(`PythonCore\\${version.toString()}-${suffix}`); + } + } + let best: PymanagerCandidate | undefined; + for (let index = 0; index < finalTags.length; index += QUERY_BATCH_SIZE) { + const candidates = await query(finalTags.slice(index, index + QUERY_BATCH_SIZE)); + best = candidates.find(compatible); + if (best) { + break; + } + } + if (!allowsPrereleases) { + if (best) { + return best; + } + continue; + } + + // A prerelease of a newer patch sorts above an older final release. + for (let patch = latest.version.patch; patch >= (best?.version.patch ?? 0); patch -= 1) { + for (const phase of ['rc', 'b', 'a']) { + const prefix = `${release(patch)}${phase}`; + const serials = new Set([0, 1]); + for (const bound of clauseVersions) { + serials.add(bound.releaseSerial); + if (bound.releaseSerial > 0) { + serials.add(bound.releaseSerial - 1); + } + if (bound.releaseSerial < Number.MAX_SAFE_INTEGER) { + serials.add(bound.releaseSerial + 1); + } + } + const mayMatch = [...serials].some((serial) => { + const version = new PythonVersion(`${prefix}${serial}`); + return matches(version) && (!best || version.compareTo(best.version) > 0); + }); + if (!mayMatch) { + continue; + } + const prefixCandidates = await query([`PythonCore\\${prefix}-${suffix}`]); + const newest = prefixCandidates.find((candidate) => + candidate.version.major === latest.version.major && + candidate.version.minor === latest.version.minor && + candidate.version.patch === patch && + candidate.version.releaseLevel === new PythonVersion(`${prefix}0`).releaseLevel, + ); + if (!newest) { + continue; + } + if (compatible(newest) && (!best || newest.version.compareTo(best.version) > 0)) { + best = newest; + continue; + } + if (newest.version.releaseSerial > MAX_RELEASE_COMPONENT) { + throw new PythonInstallationError('catalogue-failed', 'The Python catalogue has an unsupported prerelease range.'); + } + const tags: string[] = []; + for (let serial = newest.version.releaseSerial - 1; serial >= 0; serial -= 1) { + const version = new PythonVersion(`${prefix}${serial}`); + if (matches(version) && (!best || version.compareTo(best.version) > 0)) { + tags.push(`PythonCore\\${version.toString()}-${suffix}`); + } + } + for (let index = 0; index < tags.length; index += QUERY_BATCH_SIZE) { + const candidates = await query(tags.slice(index, index + QUERY_BATCH_SIZE)); + const matched = candidates.find(compatible); + if (matched) { + best = matched; + break; + } + } + } + } + if (best && (!specifier || specifier.matches(best.version))) { + return best; + } + } + return undefined; +} diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 2803674b3..bede37b24 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -1,4 +1,6 @@ import * as path from 'path'; +import * as fs from 'fs-extra'; +import { promises as nativeFs } from 'fs'; import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, @@ -18,11 +20,13 @@ import { SetEnvironmentScope, } from '../../api'; import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; -import { SysManagerStrings } from '../../common/localize'; +import { PythonInstallStrings, SysManagerStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; -import { normalizePath } from '../../common/utils/pathUtils'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; +import { PythonVersion } from '../../common/pythonVersion'; +import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; +import { showErrorMessage, withProgress } from '../../common/window.apis'; import { EnvironmentToolSupport, pythonToolSupport } from '../../internal/pythonToolSupport'; -import { withProgress } from '../../common/window.apis'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { @@ -33,8 +37,20 @@ import { setSystemEnvForWorkspace, setSystemEnvForWorkspaces, } from './cache'; -import { getDefaultGlobalPython, refreshPythons, resolveSystemPythonEnvironmentPath } from './utils'; -import { installPythonWithUv, promptInstallPythonViaUv, selectPythonVersionToInstall } from './uvPythonInstaller'; +import { + getDefaultGlobalPython, + getSystemPythonInfo, + refreshPythons, + resolveSystemPythonEnvironmentPath, +} from './utils'; +import { promptInstallPython, selectAndInstallPython } from './pythonInstaller'; +import { detectPymanager, listPymanagerRuntimes, PymanagerRuntime } from './pymanagerPythonInstaller'; + +interface SystemPythonInventory { + readonly collection: PythonEnvironment[]; + readonly pymanagerPaths: Set; + readonly retainedPymanagerPaths: Set; +} export class SysPythonManager implements EnvironmentManager { readonly capabilities: Capabilities = { @@ -52,6 +68,9 @@ export class SysPythonManager implements EnvironmentManager { private collection: PythonEnvironment[] = []; private readonly fsPathToEnv: Map = new Map(); private globalEnv: PythonEnvironment | undefined; + private pymanagerPaths = new Set(); + private retainedPymanagerPaths = new Set(); + private inventoryOperations: Promise = Promise.resolve(); private readonly _onDidChangeEnvironment = new EventEmitter(); public readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; @@ -101,23 +120,12 @@ export class SysPythonManager implements EnvironmentManager { this._initialized = createDeferred(); try { await this.initializeDiscovery(); + + // Only acquire a runtime after discovery has completed; the installer must not re-enter initialize(). if (this.collection.length === 0) { - const pythonPath = await promptInstallPythonViaUv('activation', this.log); + const pythonPath = await promptInstallPython('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 }, - ]); - } + await this.selectInstalledPython(pythonPath); } } } finally { @@ -145,7 +153,21 @@ export class SysPythonManager implements EnvironmentManager { return this.internalRefresh(true, SysManagerStrings.sysManagerRefreshing); } - private async internalRefresh(hardRefresh: boolean, title: string) { + private enqueueInventoryOperation(operation: () => Promise): Promise { + const next = this.inventoryOperations.then(operation); + this.inventoryOperations = next.then(() => undefined, () => undefined); + return next; + } + + private internalRefresh(hardRefresh: boolean, title: string): Promise { + return this.enqueueInventoryOperation(() => this.refreshInventory(hardRefresh, title)); + } + + private async refreshInventory( + hardRefresh: boolean, + title: string, + publishChanges: boolean = true, + ): Promise { await withProgress( { location: ProgressLocation.Window, @@ -153,10 +175,16 @@ export class SysPythonManager implements EnvironmentManager { }, async () => { const discard = this.collection.map((c) => c); + const previousPymanagerPaths = new Set(this.pymanagerPaths); - // hit here is fine... - this.collection = - (await refreshPythons(hardRefresh, this.nativeFinder, this.api, this.log, this)) ?? []; + const managerRuntimes = this.getPymanagerRuntimes(); + const native = (await refreshPythons(hardRefresh, this.nativeFinder, this.api, this.log, this)) ?? []; + const inventory = await this.includePymanagerRuntimes( + native, discard, await managerRuntimes, previousPymanagerPaths, + ); + this.collection = inventory.collection; + this.pymanagerPaths = inventory.pymanagerPaths; + this.retainedPymanagerPaths = inventory.retainedPymanagerPaths; await this.loadEnvMap(); const args = [ @@ -164,12 +192,107 @@ export class SysPythonManager implements EnvironmentManager { ...this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), ]; - this._onDidChangeEnvironments.fire(args); + if (publishChanges) { + this._onDidChangeEnvironments.fire(args); + } this.initializationError = undefined; }, ); } + private async getPymanagerRuntimes(): Promise { + try { + const manager = await detectPymanager(); + if (manager.kind === 'absent') { + return []; + } + if (manager.kind === 'unusable') { + this.log.warn(`PyManager runtime discovery is unavailable: ${manager.error.message}`); + return undefined; + } + return await listPymanagerRuntimes(manager.executable, { onlyManaged: true }); + } catch (error) { + this.log.warn(`Could not discover PyManager runtimes; retaining previous results: ${error}`); + return undefined; + } + } + + private async includePymanagerRuntimes( + native: PythonEnvironment[], + previous: PythonEnvironment[], + runtimes: PymanagerRuntime[] | undefined, + previousPymanagerPaths: ReadonlySet, + ): Promise { + const result = new Map(native.map((environment) => [normalizePath(environment.environmentPath.fsPath), environment])); + const retainedPaths = new Set(); + if (runtimes === undefined) { + for (const environment of previous) { + const key = normalizePath(environment.environmentPath.fsPath); + if (previousPymanagerPaths.has(key) && !result.has(key)) { + result.set(key, environment); + } + } + return { + collection: [...result.values()], + pymanagerPaths: new Set(previousPymanagerPaths), + retainedPymanagerPaths: new Set(previousPymanagerPaths), + }; + } + const nextPaths = new Set(); + for (const runtime of runtimes) { + if ( + runtime.unmanaged || + runtime.company.toLowerCase() !== 'pythoncore' || + runtime.executableArgs.length > 0 || + !runtime.prefix || + !PythonVersion.tryParse(runtime.version) || + !path.isAbsolute(runtime.prefix) || + !path.isAbsolute(runtime.executable) || + !isSameOrParentPath(runtime.prefix, runtime.executable) || + !/^python(?:\d+(?:\.\d+)*)?t?\.exe$/i.test(path.basename(runtime.executable)) + ) { + continue; + } + let key = normalizePath(runtime.executable); + try { + if (!(await fs.stat(runtime.executable)).isFile() || + await fs.pathExists(path.join(runtime.prefix, 'pyvenv.cfg'))) { + continue; + } + const [executable, prefix] = await Promise.all([ + nativeFs.realpath(runtime.executable), + nativeFs.realpath(runtime.prefix), + ]); + if (!isSameOrParentPath(prefix, executable)) { + this.log.warn('A PyManager runtime resolved outside its reported prefix.'); + continue; + } + key = normalizePath(executable); + nextPaths.add(key); + const discoveredVersion = PythonVersion.tryParse(result.get(key)?.version); + const managedVersion = PythonVersion.tryParse(runtime.version); + if (!result.has(key) || !discoveredVersion || managedVersion?.compareTo(discoveredVersion) !== 0) { + result.set(key, this.api.createPythonEnvironmentItem(getSystemPythonInfo({ + executable, + version: runtime.version, + prefix, + }), this)); + } + } catch (error) { + if (!isFileNotFoundError(error)) { + this.log.warn(`Could not inspect a PyManager runtime: ${error}`); + const known = previous.find((environment) => normalizePath(environment.environmentPath.fsPath) === key); + if (known) { + result.set(key, known); + nextPaths.add(key); + retainedPaths.add(key); + } + } + } + } + return { collection: [...result.values()], pymanagerPaths: nextPaths, retainedPymanagerPaths: retainedPaths }; + } + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { await (toolExecution ? this.initializeDiscovery() : this.initialize()); @@ -315,38 +438,73 @@ export class SysPythonManager implements EnvironmentManager { } /** - * Installs a global Python using uv. + * Installs a global Python using the platform's available runtime installer. * 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(this.log); - if (!selectedVersion) { - // User cancelled - return undefined; - } + const pythonPath = await selectAndInstallPython(this.log); + return pythonPath ? this.selectInstalledPython(pythonPath) : undefined; + } - const pythonPath = await installPythonWithUv(this.log, selectedVersion); + private selectInstalledPython(pythonPath: string): Promise { + // Keep refresh publication and its dependent freshness decision in one operation. + return this.enqueueInventoryOperation(() => this.selectInstalledPythonCore(pythonPath)); + } - if (pythonPath) { - // Resolve the installed Python using NativePythonFinder instead of full refresh - const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); - if (resolved) { - // Reinstalling an already-installed version resolves to an interpreter that is already listed. - const environment = this.trackResolvedEnvironment(resolved); - this.globalEnv = environment; - await setSystemEnvForGlobal(environment.environmentPath.fsPath); - if (environment === resolved) { - this._onDidChangeEnvironments.fire([{ environment, kind: EnvironmentChangeKind.add }]); - } - return environment; + private async selectInstalledPythonCore(pythonPath: string): Promise { + // Refresh also supplies PyManager installations which PET cannot discover. + // Do not select a previous inventory entry if that refresh failed. + const previous = this.findEnvironmentByPath(pythonPath); + let refreshed = false; + try { + await this.refreshInventory(true, SysManagerStrings.sysManagerRefreshing, false); + refreshed = true; + } catch (error) { + this.log.warn(`Python was installed, but discovery could not be refreshed: ${error}`); + } + const discovered = refreshed ? this.findEnvironmentByPath(pythonPath) : undefined; + const fresh = discovered && !this.retainedPymanagerPaths.has(normalizePath(discovered.environmentPath.fsPath)) + ? discovered + : undefined; + const resolved = fresh ?? + await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); + if (!resolved) { + this.log.error(`The installed Python could not be resolved at ${pythonPath}.`); + void showErrorMessage(PythonInstallStrings.discoveryFailed); + return undefined; + } + const existingIndex = this.collection.findIndex( + (environment) => normalizePath(environment.environmentPath.fsPath) === normalizePath(resolved.environmentPath.fsPath), + ); + const current = existingIndex >= 0 ? this.collection[existingIndex] : undefined; + const selected = current?.version === resolved.version ? current : resolved; + if (existingIndex >= 0) { + this.collection[existingIndex] = selected; + } else { + this.collection.push(selected); + } + this.retainedPymanagerPaths.delete(normalizePath(selected.environmentPath.fsPath)); + for (const [scope, environment] of this.fsPathToEnv) { + if (normalizePath(environment.environmentPath.fsPath) === normalizePath(selected.environmentPath.fsPath)) { + this.fsPathToEnv.set(scope, selected); } } - - return undefined; + this.globalEnv = selected; + await setSystemEnvForGlobal(selected.environmentPath.fsPath); + const selectionChanged = + !previous || + previous.version !== selected.version || + normalizePath(previous.environmentPath.fsPath) !== normalizePath(selected.environmentPath.fsPath); + if (selectionChanged) { + this._onDidChangeEnvironments.fire([ + ...(previous ? [{ environment: previous, kind: EnvironmentChangeKind.remove }] : []), + { environment: selected, kind: EnvironmentChangeKind.add }, + ]); + } + return selected; } async clearCache(): Promise { diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 16058f40f..f884d6484 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -71,7 +71,8 @@ function getKindName(kind: NativePythonEnvironmentKind | undefined): string | un } } -function getPythonInfo(env: NativeEnvInfo, uvManaged: boolean = false): PythonEnvironmentInfo { +/** Create the ordinary System-manager presentation and execution contract from resolved runtime metadata. */ +export function getSystemPythonInfo(env: NativeEnvInfo, uvManaged: boolean = false): PythonEnvironmentInfo { if (env.executable && env.version && env.prefix) { const kindName = uvManaged ? 'uv' : getKindName(env.kind); const sv = shortenVersionString(env.version); @@ -202,7 +203,7 @@ export async function refreshPythons( } envs.forEach(({ env, uvManaged }) => { try { - const envInfo = getPythonInfo(env, uvManaged); + const envInfo = getSystemPythonInfo(env, uvManaged); const python = api.createPythonEnvironmentItem(envInfo, manager); if (uvManaged) { uvManagedPythons.add(python); @@ -266,7 +267,7 @@ export async function resolveSystemPythonEnvironmentPath( // This is supposed to handle a python interpreter as long as we know some basic things about it if (resolved.executable && resolved.version && resolved.prefix) { const uvManaged = await isUvManagedPythonInstall(resolved); - const python = api.createPythonEnvironmentItem(getPythonInfo(resolved, uvManaged), manager); + const python = api.createPythonEnvironmentItem(getSystemPythonInfo(resolved, uvManaged), manager); if (uvManaged) { uvManagedPythons.add(python); } diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 0d24cc4b0..5a1dd3302 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -54,7 +54,7 @@ export type PromptInstallPythonViaUvResult = | { readonly kind: 'declined' } | { readonly kind: 'failed' }; -function sanitizePromptDetail(value: string | undefined): string | undefined { +export function sanitizePromptDetail(value: string | undefined): string | undefined { const normalized = value?.replace(PROMPT_CONTROL_CHARACTERS, ' ').replace(/\s+/g, ' ').trim(); if (!normalized) { return undefined; diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index c05669d14..12f53417f 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -32,7 +32,7 @@ import { import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; import { executeCommand } from '../../common/command.api'; import { PYTHON_EXTENSION_ID } from '../../common/constants'; -import { VenvManagerStrings } from '../../common/localize'; +import { PythonInstallStrings, VenvManagerStrings } from '../../common/localize'; import { traceError, traceWarn } from '../../common/logging'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -50,8 +50,8 @@ import { findParentIfFile } from '../../features/envCommands'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, shortenVersionString, sortEnvironments } from '../common/utils'; -import { getDefaultGlobalPython } from './utils'; -import { promptInstallPythonViaUv } from './uvPythonInstaller'; +import { promptInstallPython } from './pythonInstaller'; +import { getDefaultGlobalPython, resolveSystemPythonEnvironmentPath } from './utils'; import { clearVenvCache, CreateEnvironmentResult, @@ -290,22 +290,33 @@ export class VenvManager implements EnvironmentManager { let globals = await this.api.getEnvironments('global'); - // If no Python environments found, offer to install Python via uv + // Runtime acquisition is separate from the venv/package backend selection. if (globals.length === 0) { - const installedPath = await promptInstallPythonViaUv('createEnvironment', this.log); - if (installedPath) { - // Refresh environments to detect the newly installed Python + const installedPath = await promptInstallPython('createEnvironment', this.log); + if (!installedPath) { + return undefined; + } + try { 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) => PythonVersion.tryParse(e.version)?.major === 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); - } + } catch (error) { + this.log.warn(`Python was installed, but discovery could not be refreshed: ${error}`); + } + const installed = globals.find((environment) => + normalizePath(environment.execInfo.run.executable) === normalizePath(installedPath)) ?? + await resolveSystemPythonEnvironmentPath( + installedPath, this.nativeFinder, this.api, this.baseManager, + ); + if (!installed || PythonVersion.tryParse(installed.version)?.major !== 3) { + this.log.error(`The installed Python could not be resolved at ${installedPath}.`); + showErrorMessage(PythonInstallStrings.discoveryFailed); + return undefined; + } + if (!globals.some((environment) => + normalizePath(environment.execInfo.run.executable) === normalizePath(installed.execInfo.run.executable))) { + globals = [...globals, installed]; } + this.globalEnv = installed; } let result: CreateEnvironmentResult | undefined = undefined; diff --git a/src/managers/common/packageWatcher.ts b/src/managers/common/packageWatcher.ts index 2718fd931..4b21b92e3 100644 --- a/src/managers/common/packageWatcher.ts +++ b/src/managers/common/packageWatcher.ts @@ -1,12 +1,48 @@ import * as path from 'path'; +import { promises as fs } from 'fs'; import { Disposable, Event, LogOutputChannel, RelativePattern, Terminal, Uri } from 'vscode'; import { PackageManager, PythonEnvironment } from '../../api'; +import { traceWarn } from '../../common/logging'; import { createSimpleDebounce } from '../../common/utils/debounce'; +import { normalizePath } from '../../common/utils/pathUtils'; import { onDidCloseTerminal } from '../../common/window.apis'; import { pathExists } from '../../common/workspace.fs.apis'; import { createFileSystemWatcher, getConfiguration, onDidChangeConfiguration } from '../../common/workspace.apis'; import type { EnvironmentManagers } from '../../features/envManagers'; +const pausedRuntimePrefixes = new Map(); +const runtimeWatcherPauseListeners = new Set<() => Promise>(); + +async function reconcileRuntimeWatchers(): Promise { + await Promise.all([...runtimeWatcherPauseListeners].map((listener) => listener())); +} + +/** + * Releases this extension's package watchers while an installer replaces a base runtime. + * Windows cannot rename an installation containing a watched directory. + * @param prefix The physically resolved runtime prefix. + * @param operation The approved installation and verification operation. + * @returns The operation result, restoring watchers even on failure or cancellation. + */ +export async function withPackageWatchersPaused(prefix: string, operation: () => Promise): Promise { + const key = normalizePath(path.resolve(prefix)); + pausedRuntimePrefixes.set(key, (pausedRuntimePrefixes.get(key) ?? 0) + 1); + try { + await reconcileRuntimeWatchers(); + return await operation(); + } finally { + const remaining = (pausedRuntimePrefixes.get(key) ?? 1) - 1; + if (remaining === 0) { + pausedRuntimePrefixes.delete(key); + } else { + pausedRuntimePrefixes.set(key, remaining); + } + await reconcileRuntimeWatchers().catch((error) => { + traceWarn('Could not restore package watchers after Python installation:', error); + }); + } +} + export interface PackageWatcherTerminalActivation { onDidChangeTerminalActivationState: Event<{ terminal: Terminal; @@ -148,13 +184,46 @@ export function registerPackageWatchers( } type WatcherConsumer = string | Terminal; + interface DesiredWatcher { + readonly context: Uri | PythonEnvironment | undefined; + readonly environment: PythonEnvironment; + prefixKey: Promise; + } const activeWatcherByConsumer = new Map(); + const desiredWatchers = new Map(); const activeEnvironmentByScope = new Map< string, { scope: Uri | undefined; environment: PythonEnvironment } >(); - const sharedWatchers = new Map(); + const sharedWatchers = new Map< + string, + { disposable: Disposable; references: number; prefixKey: Promise } + >(); const closedTerminals = new WeakSet(); + let disposed = false; + + const resolvePrefixKey = async (prefix: string): Promise => { + if (!prefix) { + return undefined; + } + let deadline: ReturnType | undefined; + try { + const physical = await Promise.race([ + fs.realpath(prefix), + new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new Error('Package watcher prefix resolution timed out.')), 1000); + }), + ]); + return normalizePath(physical); + } catch (error) { + if (!disposed) { + log.warn(`Could not resolve package watcher prefix; it will be paused conservatively during runtime updates: ${error}`); + } + return undefined; + } finally { + clearTimeout(deadline); + } + }; const releaseConsumer = (consumer: WatcherConsumer): void => { const watcherKey = activeWatcherByConsumer.get(consumer); @@ -175,7 +244,7 @@ export function registerPackageWatchers( } }; - const watchEnvironment = ( + const attachWatcher = ( consumer: WatcherConsumer, packageManagerContext: Uri | PythonEnvironment | undefined, environment: PythonEnvironment, @@ -191,13 +260,17 @@ export function registerPackageWatchers( const packageManagerKey = `${selectedPackageManager.id}:${selectedPackageManager.project?.uri.toString() ?? ''}`; const watcherKey = `${environment.envId.managerId}:${environment.envId.id}:${packageManagerKey}`; + const desired = desiredWatchers.get(consumer); + const sharedWatcher = sharedWatchers.get(watcherKey); + if (desired && sharedWatcher) { + desired.prefixKey = sharedWatcher.prefixKey; + } if (activeWatcherByConsumer.get(consumer) === watcherKey) { return; } releaseConsumer(consumer); - const sharedWatcher = sharedWatchers.get(watcherKey); if (sharedWatcher) { sharedWatcher.references += 1; } else { @@ -220,11 +293,49 @@ export function registerPackageWatchers( resolvePackageManager, ), references: 1, + prefixKey: desired?.prefixKey ?? resolvePrefixKey(environment.sysPrefix), }); } activeWatcherByConsumer.set(consumer, watcherKey); }; + const reconcileConsumer = async (consumer: WatcherConsumer, desired: DesiredWatcher): Promise => { + const prefixKey = await desired.prefixKey; + if (disposed || desiredWatchers.get(consumer) !== desired) { + return; + } + if (pausedRuntimePrefixes.size > 0 && (!prefixKey || pausedRuntimePrefixes.has(prefixKey))) { + releaseConsumer(consumer); + } else { + attachWatcher(consumer, desired.context, desired.environment); + } + }; + + const watchEnvironment = ( + consumer: WatcherConsumer, + context: Uri | PythonEnvironment | undefined, + environment: PythonEnvironment, + ): void => { + const previous = desiredWatchers.get(consumer); + const desired: DesiredWatcher = { + context, + environment, + prefixKey: previous?.environment === environment + ? previous.prefixKey + : resolvePrefixKey(environment.sysPrefix), + }; + desiredWatchers.set(consumer, desired); + if (pausedRuntimePrefixes.size > 0) { + // Do not create an alias-spelled watcher while its physical identity is pending. + releaseConsumer(consumer); + void reconcileConsumer(consumer, desired).catch((error) => { + log.error(`Could not reconcile package watchers during Python installation: ${error}`); + }); + } else { + attachWatcher(consumer, context, environment); + } + }; + const environmentChangeDisposable = envManagers.onDidChangeActiveEnvironment((changes) => { const scopeKey = changes.uri?.toString() ?? 'global'; if (changes.new) { @@ -232,6 +343,7 @@ export function registerPackageWatchers( watchEnvironment(scopeKey, changes.uri, changes.new); } else { activeEnvironmentByScope.delete(scopeKey); + desiredWatchers.delete(scopeKey); releaseConsumer(scopeKey); } }); @@ -257,15 +369,22 @@ export function registerPackageWatchers( watchEnvironment(changes.terminal, managerContext, changes.environment); } } else { + desiredWatchers.delete(changes.terminal); releaseConsumer(changes.terminal); } }); const terminalCloseDisposable = onDidCloseTerminal((terminal) => { closedTerminals.add(terminal); + desiredWatchers.delete(terminal); releaseConsumer(terminal); }); + const reconcile = async (): Promise => { + await Promise.all([...desiredWatchers].map(([consumer, desired]) => reconcileConsumer(consumer, desired))); + }; + runtimeWatcherPauseListeners.add(reconcile); + const configurationChangeDisposable = onDidChangeConfiguration((changes) => { if ( !changes.affectsConfiguration('python-envs.defaultPackageManager') && @@ -280,13 +399,16 @@ export function registerPackageWatchers( }); return new Disposable(() => { + disposed = true; environmentChangeDisposable.dispose(); terminalActivationDisposable.dispose(); terminalCloseDisposable.dispose(); configurationChangeDisposable.dispose(); + runtimeWatcherPauseListeners.delete(reconcile); sharedWatchers.forEach(({ disposable }) => disposable.dispose()); sharedWatchers.clear(); activeWatcherByConsumer.clear(); activeEnvironmentByScope.clear(); + desiredWatchers.clear(); }); } diff --git a/src/test/common/installerProcess.unit.test.ts b/src/test/common/installerProcess.unit.test.ts new file mode 100644 index 000000000..d22b22f7c --- /dev/null +++ b/src/test/common/installerProcess.unit.test.ts @@ -0,0 +1,434 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { ChildProcess, ChildProcessWithoutNullStreams } from 'child_process'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { PassThrough } from 'stream'; +import { CancellationError, CancellationToken, CancellationTokenSource, Disposable } from 'vscode'; +import * as childProcessApis from '../../common/childProcess.apis'; +import { InstallerProcessError, runInstallerProcess } from '../../common/installerProcess'; +import * as logging from '../../common/logging'; + +suite('installerProcess', () => { + const executable = path.join(process.cwd(), 'installer fixtures', 'pymanager.exe'); + let child: ChildProcessWithoutNullStreams & { stdin: PassThrough }; + let spawn: sinon.SinonStub; + let kill: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + let tokens: CancellationTokenSource[]; + + setup(() => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + child = Object.assign(new ChildProcess(), { + stdin, + stdout, + stderr, + stdio: [stdin, stdout, stderr, undefined, undefined] as ChildProcessWithoutNullStreams['stdio'], + }); + spawn = sinon.stub(childProcessApis, 'spawnProcess').returns(child); + kill = sinon.stub(child, 'kill').returns(true); + sinon.stub(logging, 'traceVerbose'); + clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + tokens = []; + }); + + teardown(() => { + for (const token of tokens) { + token.dispose(); + } + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + sinon.restore(); + }); + + function tokenSource(): CancellationTokenSource { + const source = new CancellationTokenSource(); + tokens.push(source); + return source; + } + + function assertCleanedUp(): void { + assert.strictEqual(clock.countTimers(), 0); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(child.stdout.listenerCount('data'), 0); + assert.strictEqual(child.stderr.listenerCount('data'), 0); + for (const emitter of [child, child.stdin, child.stdout, child.stderr]) { + assert.strictEqual(emitter.listenerCount('error'), 1, 'Only the stateless late-error guard remains'); + } + } + + function expectFailure( + promise: Promise, + kind: InstallerProcessError['kind'], + uncertain: boolean, + ): Promise { + return assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof InstallerProcessError); + assert.strictEqual(error.kind, kind); + assert.strictEqual(error.processMayStillBeRunning, uncertain); + return true; + }); + } + + function expectCancellation(promise: Promise, uncertain: boolean): Promise { + return assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof CancellationError); + assert.ok('processMayStillBeRunning' in error); + assert.strictEqual(error.processMayStillBeRunning, uncertain); + return true; + }); + } + + test('uses argv and explicit pipes without replacing the wrapper environment', async () => { + const args = Object.freeze(['list', '<3.15', 'a b & c']); + const pending = runInstallerProcess(executable, args); + sinon.assert.calledOnceWithExactly(spawn, executable, args, { + shell: false, + windowsHide: true, + stdio: 'pipe', + }); + assert.notStrictEqual(spawn.firstCall.args[1], args); + assert.strictEqual(child.stdin.writableEnded, true); + assert.strictEqual(child.stdin.read(), null); + child.emit('close', 0, null); + assert.deepStrictEqual(await pending, { stdout: '', stderr: '' }); + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('waits for close and captures output that arrives after exit, separately', async () => { + let completed = false; + const pending = runInstallerProcess(executable, []).then((value) => { + completed = true; + return value; + }); + child.stdout.emit('data', Buffer.from('first')); + child.stderr.emit('data', Buffer.from('diagnostic')); + child.emit('exit', 0, null); + await Promise.resolve(); + assert.strictEqual(completed, false); + child.stdout.emit('data', Buffer.from(' last')); + child.stderr.emit('data', Buffer.from(' last')); + child.emit('close', 0, null); + assert.deepStrictEqual(await pending, { stdout: 'first last', stderr: 'diagnostic last' }); + assertCleanedUp(); + }); + + test('decodes split UTF-8 independently for each stream and forwards only on opt-in', async () => { + const onOutput = sinon.spy(); + const pending = runInstallerProcess(executable, [], { onOutput }); + const snake = Buffer.from('🐍'); + const accent = Buffer.from('é'); + child.stdout.emit('data', snake.subarray(0, 2)); + child.stderr.emit('data', accent.subarray(0, 1)); + child.stdout.emit('data', snake.subarray(2)); + child.stderr.emit('data', accent.subarray(1)); + child.stdout.emit('data', '!'); + child.emit('close', 0, null); + assert.deepStrictEqual(await pending, { stdout: '🐍!', stderr: 'é' }); + assert.deepStrictEqual(onOutput.args, [['🐍'], ['é'], ['!']]); + assertCleanedUp(); + }); + + test('flushes incomplete UTF-8 at close', async () => { + const onOutput = sinon.spy(); + const pending = runInstallerProcess(executable, [], { onOutput }); + child.stdout.emit('data', Buffer.from([0xe2])); + child.emit('close', 0, null); + assert.deepStrictEqual(await pending, { stdout: '\ufffd', stderr: '' }); + sinon.assert.calledOnceWithExactly(onOutput, '\ufffd'); + }); + + test('wraps synchronous spawn failures without disclosing native details', async () => { + const cause = new Error('sensitive-spawn-detail'); + spawn.throws(cause); + await assert.rejects(runInstallerProcess(executable, ['sensitive-argument']), (error: unknown) => { + assert.ok(error instanceof InstallerProcessError); + assert.strictEqual(error.kind, 'spawn'); + assert.strictEqual(error.cause, cause); + assert.strictEqual(error.processMayStillBeRunning, false); + assert.ok(!error.message.includes('sensitive')); + return true; + }); + assert.strictEqual(clock.countTimers(), 0); + sinon.assert.notCalled(kill); + }); + + test('handles asynchronous spawn failure, closes and then cleans up', async () => { + const checked = expectFailure(runInstallerProcess(executable, []), 'spawn', false); + child.emit('error', new Error('ENOENT')); + child.emit('close', -2, null); + await checked; + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('bounds the wait for close after a spawn failure', async () => { + const checked = expectFailure(runInstallerProcess(executable, []), 'spawn', false); + child.emit('error', new Error('ENOENT')); + await clock.tickAsync(1000); + await checked; + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('rejects nonzero close without exposing stderr or arguments', async () => { + const pending = runInstallerProcess(executable, ['sensitive-argument']); + const checked = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof InstallerProcessError); + assert.strictEqual(error.kind, 'exit'); + assert.strictEqual(error.exitCode, 23); + assert.strictEqual(error.processMayStillBeRunning, false); + assert.ok(!error.message.includes('sensitive')); + return true; + }); + child.stderr.emit('data', Buffer.from('sensitive-stderr')); + child.emit('exit', 23, null); + child.emit('close', 23, null); + await checked; + assertCleanedUp(); + }); + + test('reports a signal-only exit as failure with uncertainty', async () => { + const checked = expectFailure(runInstallerProcess(executable, []), 'exit', true); + child.emit('close', null, 'SIGTERM'); + await checked; + }); + + test('signals only the child on timeout and still waits for close', async () => { + let completed = false; + const checked = expectFailure(runInstallerProcess(executable, [], { timeoutMs: 20 }), 'timeout', true).then( + () => { + completed = true; + }, + ); + await clock.tickAsync(20); + sinon.assert.calledOnceWithExactly(kill); + assert.strictEqual(completed, false); + child.emit('close', null, 'SIGTERM'); + await checked; + assertCleanedUp(); + }); + + test('settles after a bounded termination grace and safely handles late events', async () => { + const onOutput = sinon.spy(); + const checked = expectFailure( + runInstallerProcess(executable, [], { timeoutMs: 20, onOutput }), + 'timeout', + true, + ); + await clock.tickAsync(1020); + await checked; + assertCleanedUp(); + assert.doesNotThrow(() => { + child.emit('error', new Error('late process error')); + child.stdin.emit('error', Object.assign(new Error('late input error'), { code: 'EPIPE' })); + child.stdout.emit('error', new Error('late stdout error')); + child.stderr.emit('error', new Error('late stderr error')); + child.stdout.emit('data', Buffer.from('late output')); + child.emit('close', 0, null); + }); + sinon.assert.notCalled(onOutput); + }); + + test('does not signal an already exited child when its streams never close', async () => { + const checked = expectFailure(runInstallerProcess(executable, [], { timeoutMs: 20 }), 'timeout', true); + child.emit('exit', 0, null); + await clock.tickAsync(1020); + await checked; + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('rejects an already-cancelled token before spawning or allocating timers', async () => { + const source = tokenSource(); + source.cancel(); + await expectCancellation(runInstallerProcess(executable, [], { cancellationToken: source.token }), false); + sinon.assert.notCalled(spawn); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('waits for close on cancellation and disposes the token subscription', async () => { + const source = tokenSource(); + const registration = sinon.spy(source.token, 'onCancellationRequested'); + let completed = false; + const checked = expectCancellation( + runInstallerProcess(executable, [], { cancellationToken: source.token }), + true, + ).then(() => { + completed = true; + }); + const subscription: Disposable = registration.firstCall.returnValue; + const dispose = sinon.spy(subscription, 'dispose'); + source.cancel(); + await Promise.resolve(); + assert.strictEqual(completed, false); + sinon.assert.calledOnceWithExactly(kill); + child.emit('close', null, 'SIGTERM'); + await checked; + sinon.assert.calledOnce(dispose); + source.cancel(); + sinon.assert.calledOnce(kill); + assertCleanedUp(); + }); + + test('disposes cancellation after success so later cancellation cannot kill another process', async () => { + const source = tokenSource(); + const pending = runInstallerProcess(executable, [], { cancellationToken: source.token }); + child.emit('close', 0, null); + await pending; + source.cancel(); + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('catches cancellation that occurs between the initial check and subscribing', async () => { + let reads = 0; + const dispose = sinon.spy(); + const token: CancellationToken = { + get isCancellationRequested() { + return reads++ > 0; + }, + onCancellationRequested: () => ({ dispose }), + }; + const checked = expectCancellation(runInstallerProcess(executable, [], { cancellationToken: token }), true); + child.emit('close', null, 'SIGTERM'); + await checked; + sinon.assert.calledOnce(kill); + sinon.assert.calledOnce(dispose); + assertCleanedUp(); + }); + + test('disposes a synchronously firing cancellation registration even when kill synchronously closes', async () => { + const dispose = sinon.spy(); + const token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: (listener) => { + listener(undefined); + return { dispose }; + }, + }; + kill.callsFake(() => { + child.emit('close', null, 'SIGTERM'); + return true; + }); + await expectCancellation(runInstallerProcess(executable, [], { cancellationToken: token }), true); + sinon.assert.calledOnce(dispose); + assertCleanedUp(); + }); + + for (const behavior of ['throws', 'emits-error', 'returns-false'] as const) { + test(`preserves cancellation and uncertainty when kill ${behavior}`, async () => { + const source = tokenSource(); + if (behavior === 'throws') { + kill.throws(new Error('EPERM')); + } else if (behavior === 'emits-error') { + kill.callsFake(() => { + child.emit('error', new Error('EPERM')); + return false; + }); + } else { + kill.returns(false); + } + const checked = expectCancellation( + runInstallerProcess(executable, [], { cancellationToken: source.token }), + true, + ); + source.cancel(); + await clock.tickAsync(1000); + await checked; + sinon.assert.calledOnce(kill); + assertCleanedUp(); + }); + } + + test('tolerates stdin EPIPE while waiting for the real process result', async () => { + const pending = runInstallerProcess(executable, []); + child.stdin.emit('error', Object.assign(new Error('input closed'), { code: 'EPIPE' })); + child.emit('close', 0, null); + await pending; + sinon.assert.notCalled(kill); + assertCleanedUp(); + }); + + test('converts a synchronous stdin write error into a bounded failure', async () => { + sinon.stub(child.stdin, 'end').throws(new Error('write failed')); + const checked = expectFailure(runInstallerProcess(executable, []), 'exit', true); + await clock.tickAsync(1000); + await checked; + sinon.assert.calledOnce(kill); + assertCleanedUp(); + }); + + test('handles output stream errors without unhandled error events', async () => { + const checked = expectFailure(runInstallerProcess(executable, []), 'exit', true); + child.stdout.emit('error', new Error('read failed')); + child.emit('close', null, 'SIGTERM'); + await checked; + assertCleanedUp(); + }); + + test('bounds the combined byte count of stdout and stderr without forwarding excess data', async () => { + const onOutput = sinon.spy(); + const checked = expectFailure( + runInstallerProcess(executable, [], { maxOutputBytes: 5, onOutput }), + 'output-limit', + true, + ); + child.stdout.emit('data', Buffer.from('é')); + child.stderr.emit('data', Buffer.from('abc')); + sinon.assert.notCalled(kill); + child.stderr.emit('data', Buffer.from('overflow')); + child.stdout.emit('data', Buffer.from('ignored')); + child.emit('close', null, 'SIGTERM'); + await checked; + assert.deepStrictEqual(onOutput.args, [['é'], ['abc']]); + sinon.assert.calledOnce(kill); + assertCleanedUp(); + }); + + test('allows output exactly at the configured bound', async () => { + const pending = runInstallerProcess(executable, [], { maxOutputBytes: 3 }); + child.stdout.emit('data', Buffer.from('é')); + child.stderr.emit('data', Buffer.from('a')); + child.emit('close', 0, null); + assert.deepStrictEqual(await pending, { stdout: 'é', stderr: 'a' }); + }); + + test('enforces a default four MiB output bound', async () => { + const checked = expectFailure(runInstallerProcess(executable, []), 'output-limit', true); + child.stdout.emit('data', Buffer.alloc(4 * 1024 * 1024 + 1)); + child.emit('close', null, 'SIGTERM'); + await checked; + assertCleanedUp(); + }); + + test('handles exceptions from output forwarding without leaking listeners', async () => { + const onOutput = sinon.stub().throws(new Error('forwarding failed')); + const checked = expectFailure(runInstallerProcess(executable, [], { onOutput }), 'exit', true); + child.stdout.emit('data', Buffer.from('progress')); + await clock.tickAsync(1000); + await checked; + assertCleanedUp(); + }); + + for (const timeoutMs of [-1, Infinity, NaN, 0.5, 2147483648]) { + test(`rejects invalid timeout ${timeoutMs} before spawning`, async () => { + await assert.rejects(runInstallerProcess(executable, [], { timeoutMs }), RangeError); + sinon.assert.notCalled(spawn); + }); + } + + for (const maxOutputBytes of [-1, Infinity, NaN, 0.5]) { + test(`rejects invalid output bound ${maxOutputBytes} before spawning`, async () => { + await assert.rejects(runInstallerProcess(executable, [], { maxOutputBytes }), RangeError); + sinon.assert.notCalled(spawn); + }); + } +}); diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index 17ee83f22..d08be4bf5 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -13,7 +13,7 @@ import { getInlineScriptMetadataRoutingIdentity, InlineScriptRoutingRegistry, } from '../../../common/inlineScript/routingRegistry'; -import { InlineScriptStrings } from '../../../common/localize'; +import { InlineScriptStrings, PythonInstallStrings } from '../../../common/localize'; import { createDeferred } from '../../../common/utils/deferred'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; @@ -335,6 +335,48 @@ suite('notifyInlineScriptSetupOutcome', () => { sinon.assert.calledTwice(infoStub); sinon.assert.notCalled(errorStub); }); + + test('preserves installer cancellation guidance about unfinished background work', () => { + const infoStub = sinon.stub(winapi, 'showInformationMessage').resolves(undefined); + const errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined); + routing.noteSetupOutcome(scriptUri, { kind: 'cancelled', message: PythonInstallStrings.cancelled }); + + notifyInlineScriptSetupOutcome(scriptUri, routing); + + sinon.assert.calledOnce(infoStub); + assert.deepStrictEqual(infoStub.firstCall.args, [PythonInstallStrings.cancelled]); + sinon.assert.notCalled(errorStub); + }); + + test('shows the provider-specific failure instead of replacing it with a generic setup error', () => { + const errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined); + routing.noteSetupOutcome(scriptUri, { + kind: 'failed', + category: 'install-failure', + message: PythonInstallStrings.managerUnusable, + }); + + notifyInlineScriptSetupOutcome(scriptUri, routing); + + sinon.assert.calledOnce(errorStub); + assert.deepStrictEqual(errorStub.firstCall.args, [PythonInstallStrings.managerUnusable]); + }); + + test('does not report an installer failure a second time', () => { + const errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined); + const warningStub = sinon.stub(winapi, 'showWarningMessage').resolves(undefined); + routing.noteSetupOutcome(scriptUri, { + kind: 'failed', + category: 'install-failure', + message: PythonInstallStrings.installFailed, + alreadyReported: true, + }); + + notifyInlineScriptSetupOutcome(scriptUri, routing); + + sinon.assert.notCalled(errorStub); + sinon.assert.notCalled(warningStub); + }); }); suite('setupInlineScriptEnvironmentHandler', () => { diff --git a/src/test/internal/discoveryIsolation.unit.test.ts b/src/test/internal/discoveryIsolation.unit.test.ts index ac9c9d00f..84ffd7eb8 100644 --- a/src/test/internal/discoveryIsolation.unit.test.ts +++ b/src/test/internal/discoveryIsolation.unit.test.ts @@ -14,9 +14,9 @@ import * as workspaceApis from '../../common/workspace.apis'; import type { PythonProjectManager } from '../../features/projectManager'; import { pythonToolSupport, ToolEnvironmentManager, waitForToolRead } from '../../internal/pythonToolSupport'; import * as cache from '../../managers/builtin/cache'; +import * as pythonInstaller from '../../managers/builtin/pythonInstaller'; import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; import * as systemUtils from '../../managers/builtin/utils'; -import * as uvInstaller from '../../managers/builtin/uvPythonInstaller'; import { VenvManager } from '../../managers/builtin/venvManager'; import * as venvUtils from '../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; @@ -91,7 +91,7 @@ suite('Agent discovery isolation from public onboarding', () => { entered.resolve(); return release.promise; }; - prompt = sinon.stub(uvInstaller, 'promptInstallPythonViaUv').callsFake(async () => { + prompt = sinon.stub(pythonInstaller, 'promptInstallPython').callsFake(async () => { await wait(); return undefined; }); diff --git a/src/test/internal/pythonToolManagers.unit.test.ts b/src/test/internal/pythonToolManagers.unit.test.ts index a7ab83b67..2ca80abbb 100644 --- a/src/test/internal/pythonToolManagers.unit.test.ts +++ b/src/test/internal/pythonToolManagers.unit.test.ts @@ -21,9 +21,9 @@ import * as cache from '../../managers/builtin/cache'; import { PipInstallCommand, PipListCommand } from '../../managers/builtin/commands'; import * as helpers from '../../managers/builtin/helpers'; import { PipPackageManager } from '../../managers/builtin/pipPackageManager'; +import * as pythonInstaller from '../../managers/builtin/pythonInstaller'; import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; import * as builtinUtils from '../../managers/builtin/utils'; -import * as uvInstaller from '../../managers/builtin/uvPythonInstaller'; import { VenvManager } from '../../managers/builtin/venvManager'; import * as venvUtils from '../../managers/builtin/venvUtils'; import { @@ -437,14 +437,14 @@ suite('Python tool manager capabilities', () => { sinon.stub(builtinUtils, 'refreshPythons').resolves([]); sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([]); sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); - const prompt = sinon.stub(uvInstaller, 'promptInstallPythonViaUv').throws(new Error('Unexpected onboarding')); - const versionPicker = sinon.stub(uvInstaller, 'selectPythonVersionToInstall').resolves(undefined); + const prompt = sinon.stub(pythonInstaller, 'promptInstallPython').throws(new Error('Unexpected onboarding')); + const installPicker = sinon.stub(pythonInstaller, 'selectAndInstallPython').resolves(undefined); const system = new SysPythonManager({} as NativePythonFinder, api, createMockLogOutputChannel()); const venv = new VenvManager({} as NativePythonFinder, api, system, createMockLogOutputChannel()); await venv[pythonToolSupport].initialize(); assert.deepStrictEqual(await system[pythonToolSupport].getEnvironments('global'), []); - assert.ok(prompt.notCalled && versionPicker.notCalled && errors.notCalled); + assert.ok(prompt.notCalled && installPicker.notCalled && errors.notCalled); await system.create('global'); - assert.ok(versionPicker.calledOnce, 'Explicit human install keeps its version picker'); + assert.ok(installPicker.calledOnce, 'Explicit human install keeps its version picker'); }); }); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 5ac2a5268..76d3a32f9 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -29,6 +29,7 @@ import * as metadataReader from '../../../../common/inlineScript/metadata'; import { InlineScriptRoutingRegistry } from '../../../../common/inlineScript/routingRegistry'; import * as lockfileApis from '../../../../common/lockfile.apis'; import { INLINE_SCRIPT_ENVS_KEY } from '../../../../common/constants'; +import { PythonInstallStrings } from '../../../../common/localize'; import { EventNames } from '../../../../common/telemetry/constants'; import * as telemetrySender from '../../../../common/telemetry/sender'; import { isWindows } from '../../../../common/utils/platformUtils'; @@ -41,6 +42,8 @@ import { InlineScriptCodeLensProvider } from '../../../../features/inlineScript/ import { InlineScriptEnvManager } from '../../../../managers/builtin/inlineScript/envManager'; import * as builtinUtils from '../../../../managers/builtin/utils'; import * as uvPythonInstaller from '../../../../managers/builtin/uvPythonInstaller'; +import * as pymanagerInstaller from '../../../../managers/builtin/pymanagerPythonInstaller'; +import * as pythonInstaller from '../../../../managers/builtin/pythonInstaller'; import * as venvUtils from '../../../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../../../managers/common/nativePythonFinder'; import { MockDocument } from '../../../mocks/mockDocument'; @@ -199,6 +202,7 @@ suite('InlineScriptEnvManager', () => { }); registerCacheKey(CACHE_KEY, VALID_METADATA.dependencies ?? [], baseExecutable); getAvailablePythonVersionsStub = sinon.stub(uvPythonInstaller, 'getAvailablePythonVersions').resolves([]); + sinon.stub(pymanagerInstaller, 'detectPymanager').resolves({ kind: 'absent' }); ensureUvForVersionLookupStub = sinon .stub(uvPythonInstaller, 'ensureUvForInlineScriptVersionLookupDetailed') .resolves('available'); @@ -793,6 +797,73 @@ suite('InlineScriptEnvManager', () => { }); }); + suite('shared runtime installer integration', () => { + test('uses the verified PyManager base without invoking the uv installation path', async () => { + const executable = path.join(tempRoot, 'pim-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(executable, ''); + const base = makeEnvironment('ms-python.python:system', '3.13.2', executable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.onThirdCall().resolves([base]); + const install = sinon.stub(pythonInstaller, 'promptInstallPythonDetailed').resolves({ + kind: 'installed', + provider: 'pymanager', + pythonPath: executable, + }); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(install, 'inlineScript', manager.log, { requiresPython: '>=3.13' }); + sinon.assert.calledOnceWithExactly(apiRefreshEnvironmentsStub, undefined); + assert.strictEqual(createWithProgressStub.firstCall.args[4], base); + sinon.assert.notCalled(promptInstallPythonViaUvStub); + sinon.assert.notCalled(ensureUvForVersionLookupStub); + }); + + test('preserves installer cancellation without refreshing discovery or creating a cache', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + sinon.stub(pythonInstaller, 'promptInstallPythonDetailed').resolves({ + kind: 'cancelled', + message: PythonInstallStrings.cancelled, + }); + const uri = scriptUri(); + + assert.strictEqual(await manager.create(uri), undefined); + + assert.deepStrictEqual(routingRegistry.getSetupOutcome(uri), { + kind: 'cancelled', + message: PythonInstallStrings.cancelled, + }); + sinon.assert.notCalled(apiRefreshEnvironmentsStub); + sinon.assert.notCalled(lockStub); + sinon.assert.notCalled(createWithProgressStub); + sinon.assert.notCalled(promptInstallPythonViaUvStub); + }); + + test('preserves provider failure details and notification ownership for the setup UI', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + sinon.stub(pythonInstaller, 'promptInstallPythonDetailed').resolves({ + kind: 'failed', + reason: 'provider-unusable', + message: PythonInstallStrings.managerUnusable, + alreadyReported: true, + }); + const uri = scriptUri(); + + assert.strictEqual(await manager.create(uri), undefined); + + assert.deepStrictEqual(routingRegistry.getSetupOutcome(uri), { + kind: 'failed', + category: 'install-failure', + requiresPython: '>=3.13', + message: PythonInstallStrings.managerUnusable, + alreadyReported: true, + }); + sinon.assert.notCalled(apiRefreshEnvironmentsStub); + sinon.assert.notCalled(createWithProgressStub); + sinon.assert.notCalled(promptInstallPythonViaUvStub); + }); + }); + suite('uv base interpreter fallback', () => { test('installs the requirement lower bound, refreshes, and uses the discovered base interpreter', async () => { const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); diff --git a/src/test/managers/builtin/pymanagerPythonInstaller.unit.test.ts b/src/test/managers/builtin/pymanagerPythonInstaller.unit.test.ts new file mode 100644 index 000000000..98bba0435 --- /dev/null +++ b/src/test/managers/builtin/pymanagerPythonInstaller.unit.test.ts @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, Uri } from 'vscode'; +import * as processRunner from '../../../common/installerProcess'; +import { InstallerProcessError, InstallerProcessResult } from '../../../common/installerProcess'; +import * as logging from '../../../common/logging'; +import * as platformUtils from '../../../common/utils/platformUtils'; +import * as installer from '../../../managers/builtin/pymanagerPythonInstaller'; + +function nativeRuntime(): Record { + return { + id: 'pythoncore-3.14-64', + company: 'PythonCore', + 'sort-version': '3.14.7', + tag: '3.14-64', + 'install-for': ['3.14-64', '3.14'], + executable: 'python.exe', + }; +} + +function output(...versions: Record[]): string { + return JSON.stringify({ versions }); +} + +suite('pymanagerPythonInstaller JSON', () => { + test('accepts zero versions and unknown properties', () => { + assert.deepStrictEqual(installer.parsePymanagerRuntimes('{"versions":[],"extra":true}'), []); + const result = installer.parsePymanagerRuntimes(output({ ...nativeRuntime(), future: { field: 3 } })); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].version, '3.14.7'); + }); + + test('uses sort-version and preserves all relevant fields without resolving relative executables', () => { + const native = { + ...nativeRuntime(), + version: 'not-the-runtime-version', + executable_args: ['-X', 'utf8'], + unmanaged: false, + }; + assert.deepStrictEqual(installer.parsePymanagerRuntimes(output(native)), [ + { + id: 'pythoncore-3.14-64', + company: 'PythonCore', + version: '3.14.7', + tag: '3.14-64', + installTags: ['3.14-64', '3.14'], + executable: 'python.exe', + executableArgs: ['-X', 'utf8'], + prefix: undefined, + unmanaged: false, + }, + ]); + }); + + test('preserves installed executable and prefix paths', () => { + const prefix = Uri.file(path.join(process.cwd(), 'runtime fixtures', 'Python 3.14')).fsPath; + const executable = Uri.file(path.join(prefix, 'python.exe')).fsPath; + const [runtime] = installer.parsePymanagerRuntimes( + output({ ...nativeRuntime(), executable, prefix, unmanaged: true }), + ); + assert.strictEqual(Uri.file(runtime.executable).fsPath, executable); + assert.strictEqual(runtime.prefix, prefix); + assert.strictEqual(runtime.unmanaged, true); + }); + + test('defaults omitted install-for, executable_args and unmanaged metadata', () => { + const native = nativeRuntime(); + delete native['install-for']; + const [runtime] = installer.parsePymanagerRuntimes(output(native)); + assert.deepStrictEqual(runtime.installTags, []); + assert.deepStrictEqual(runtime.executableArgs, []); + assert.strictEqual(runtime.unmanaged, false); + assert.strictEqual(runtime.prefix, undefined); + }); + + test('does not infer architectures, constrain versions, sort, or discard runtime families', () => { + const runtimes = [ + { ...nativeRuntime(), id: 'free-threaded', tag: '3.14t-arm64', 'sort-version': '3.14.7' }, + { ...nativeRuntime(), id: 'prerelease', tag: '3.14.0rc3-64', 'sort-version': '3.14.0rc3' }, + { ...nativeRuntime(), id: 'another-company', company: 'Other', tag: 'custom' }, + ]; + const result = installer.parsePymanagerRuntimes(output(...runtimes)); + assert.deepStrictEqual(result.map((runtime) => runtime.id), runtimes.map((runtime) => runtime.id)); + assert.strictEqual(result[1].version, '3.14.0rc3'); + }); + + test('does not include malformed JSON contents in errors', () => { + assert.throws( + () => installer.parsePymanagerRuntimes('{"versions":sensitive-data}'), + (error: unknown) => error instanceof Error && !error.message.includes('sensitive-data'), + ); + }); + + for (const invalid of ['', '[]', 'null', '1', '{}', '{"versions":null}', '{"versions":{}}', '{"versions":[null]}']) { + test(`rejects invalid native envelope ${JSON.stringify(invalid)}`, () => { + assert.throws(() => installer.parsePymanagerRuntimes(invalid), Error); + }); + } + + for (const field of ['id', 'company', 'sort-version', 'tag', 'executable']) { + test(`validates required ${field} without echoing its value`, () => { + for (const invalid of [undefined, null, 3, true, [], {}, '', ' ']) { + assert.throws( + () => installer.parsePymanagerRuntimes(output({ ...nativeRuntime(), [field]: invalid })), + (error: unknown) => error instanceof Error && error.message.includes(field), + ); + } + }); + } + + for (const field of ['install-for', 'executable_args']) { + test(`validates optional ${field} as a string array`, () => { + for (const invalid of [null, 'string', 3, {}, [3], ['good', false]]) { + assert.throws( + () => installer.parsePymanagerRuntimes(output({ ...nativeRuntime(), [field]: invalid })), + (error: unknown) => error instanceof Error && error.message.includes(field), + ); + } + }); + } + + test('validates optional prefix and unmanaged types', () => { + for (const prefix of [null, 3, false, [], {}]) { + assert.throws(() => installer.parsePymanagerRuntimes(output({ ...nativeRuntime(), prefix })), /prefix/); + } + for (const unmanaged of [null, 3, 'false', [], {}]) { + assert.throws( + () => installer.parsePymanagerRuntimes(output({ ...nativeRuntime(), unmanaged })), + /unmanaged/, + ); + } + }); +}); + +suite('pymanagerPythonInstaller listing', () => { + const executable = path.join(process.cwd(), 'installer fixtures', 'pymanager.exe'); + let run: sinon.SinonStub; + + setup(() => { + run = sinon.stub(processRunner, 'runInstallerProcess').resolves({ stdout: output(), stderr: '' }); + }); + + teardown(() => sinon.restore()); + + test('lists installed runtimes with exact argv and a finite local timeout', async () => { + assert.deepStrictEqual(await installer.listPymanagerRuntimes(executable), []); + sinon.assert.calledOnceWithExactly( + run, + executable, + ['list', '--format=json', '-q'], + { timeoutMs: 10000 }, + ); + }); + + test('lists the online catalogue with a longer bounded timeout, retaining all selectors and families', async () => { + const selectors = Object.freeze(['PythonCore\\3.14.6-64', 'PythonCore\\3.14.0rc-64']); + run.resolves({ + stdout: output(nativeRuntime(), { ...nativeRuntime(), id: 'another-family', tag: '3.14t-arm64' }), + stderr: 'not JSON', + }); + const result = await installer.listPymanagerRuntimes( + executable, + { online: true, onlyManaged: true, selectors }, + ); + assert.strictEqual(result.length, 2); + sinon.assert.calledOnceWithExactly( + run, + executable, + ['list', '--online', '--only-managed', '--format=json', '-q', ...selectors], + { timeoutMs: 60000 }, + ); + assert.strictEqual(run.firstCall.args[1].includes('--one'), false); + }); + + test('does not add online/managed switches when explicitly false', async () => { + await installer.listPymanagerRuntimes(executable, { + online: false, + onlyManaged: false, + selectors: ['<3.15'], + }); + assert.deepStrictEqual(run.firstCall.args[1], ['list', '--format=json', '-q', '<3.15']); + }); + + test('forwards inventory cancellation to the native process runner', async () => { + const source = new CancellationTokenSource(); + try { + await installer.listPymanagerRuntimes(executable, { + onlyManaged: true, + cancellationToken: source.token, + }); + + sinon.assert.calledOnceWithExactly( + run, + executable, + ['list', '--only-managed', '--format=json', '-q'], + { timeoutMs: 10000, cancellationToken: source.token }, + ); + } finally { + source.dispose(); + } + }); + + test('propagates native failures rather than converting them to an empty list', async () => { + const error = new InstallerProcessError('exit', 5); + run.rejects(error); + await assert.rejects(installer.listPymanagerRuntimes(executable), (caught: unknown) => caught === error); + }); + + test('propagates malformed native JSON despite a successful process exit', async () => { + run.resolves({ stdout: '[]', stderr: '' }); + await assert.rejects(installer.listPymanagerRuntimes(executable), /versions/); + }); +}); + +suite('pymanagerPythonInstaller detection', () => { + const fixtureRoot = path.join(process.cwd(), 'pymanager detection fixtures'); + const pathDirectory = path.join(fixtureRoot, 'PATH directory'); + const primary = path.join(pathDirectory, 'pymanager.exe'); + const localAppData = path.join(fixtureRoot, 'Local App Data'); + const programFiles = path.join(fixtureRoot, 'Program Files'); + const families = [ + 'PythonSoftwareFoundation.PythonManager_3847v3x7pw1km', + 'PythonSoftwareFoundation.PythonManager_qbz5n2kfra8p0', + ]; + const alternatives = [ + ...families.map((family) => path.join(localAppData, 'Microsoft', 'WindowsApps', family, 'pymanager.exe')), + path.join(programFiles, 'PyManager', 'pymanager.exe'), + ]; + const modulePath = require.resolve('../../../managers/builtin/pymanagerPythonInstaller'); + const whichPath = require.resolve('which'); + let subject: typeof installer; + let cachedInstaller: NodeJS.Module | undefined; + let stat: sinon.SinonStub; + let resolveExecutable: sinon.SinonStub; + let run: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + + setup(() => { + sinon.stub(platformUtils, 'isWindows').returns(true); + sinon.stub(process, 'env').value({ PATH: pathDirectory }); + sinon.stub(logging, 'traceVerbose'); + stat = sinon.stub(fs, 'lstat').rejects(Object.assign(new Error('not found'), { code: 'ENOENT' })); + resolveExecutable = sinon.stub().callsFake(async (candidate: string) => candidate); + run = sinon.stub(processRunner, 'runInstallerProcess').resolves({ stdout: output(), stderr: '' }); + clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + + // which exports a callable CommonJS module, not a stubbable named API. + // Reload only this subject against that stub, preserving other suites' module instances. + const cachedWhich = require.cache[whichPath]; + assert.ok(cachedWhich); + sinon.stub(cachedWhich, 'exports').value(resolveExecutable); + cachedInstaller = require.cache[modulePath]; + delete require.cache[modulePath]; + subject = require(modulePath); + }); + + teardown(() => { + if (cachedInstaller) { + require.cache[modulePath] = cachedInstaller; + } else { + delete require.cache[modulePath]; + } + sinon.restore(); + }); + + function known(candidate: string): void { + stat.withArgs(candidate).resolves({ isFile: () => true, isSymbolicLink: () => false }); + } + + function enableAlternatives(): void { + process.env.LOCALAPPDATA = localAppData; + process.env.ProgramFiles = programFiles; + } + + test('does not inspect files or run commands outside Windows', async () => { + (platformUtils.isWindows as sinon.SinonStub).returns(false); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'absent' }); + sinon.assert.notCalled(stat); + sinon.assert.notCalled(resolveExecutable); + sinon.assert.notCalled(run); + }); + + test('reports absent when all candidate files are missing', async () => { + enableAlternatives(); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'absent' }); + assert.deepStrictEqual(stat.args, [primary, ...alternatives].map((candidate) => [candidate])); + sinon.assert.notCalled(resolveExecutable); + sinon.assert.notCalled(run); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('accepts an installed manager with zero runtimes using the exact bounded native probe', async () => { + known(primary); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'available', executable: primary }); + sinon.assert.calledOnceWithExactly(resolveExecutable, primary, { nothrow: true, pathExt: '.EXE' }); + sinon.assert.calledOnceWithExactly( + run, + primary, + ['list', '--only-managed', '--one', '--format=json', '-q'], + { timeoutMs: 3000 }, + ); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('probes an App Execution Alias without following its inaccessible target', async () => { + stat.withArgs(primary).resolves({ isFile: () => false, isSymbolicLink: () => true }); + resolveExecutable.rejects(Object.assign(new Error('alias target cannot be opened'), { code: 'EACCES' })); + + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'available', executable: primary }); + + sinon.assert.notCalled(resolveExecutable); + assert.strictEqual(run.firstCall.args[0], primary); + }); + + for (const candidate of alternatives) { + test(`supports the documented alternative ${path.relative(fixtureRoot, candidate)}`, async () => { + enableAlternatives(); + known(candidate); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'available', executable: candidate }); + assert.strictEqual(run.firstCall.args[0], candidate); + }); + } + + test('tries a second candidate after a known manager fails', async () => { + enableAlternatives(); + known(primary); + known(alternatives[0]); + run.onFirstCall().rejects(new InstallerProcessError('exit', 1)); + assert.deepStrictEqual(await subject.detectPymanager(), { + kind: 'available', + executable: alternatives[0], + }); + assert.strictEqual(run.callCount, 2); + }); + + test('reports known but unusable and preserves the native failure', async () => { + known(primary); + const error = new InstallerProcessError('timeout', undefined, true); + run.rejects(error); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'unusable', executable: primary, error }); + }); + + test('does not classify invalid JSON as absent', async () => { + known(primary); + run.resolves({ stdout: '[]', stderr: '' }); + const result = await subject.detectPymanager(); + assert.strictEqual(result.kind, 'unusable'); + if (result.kind === 'unusable') { + assert.match(result.error.message, /versions/); + } + }); + + test('does not classify denied filesystem access as absent', async () => { + const error = Object.assign(new Error('access denied'), { code: 'EACCES' }); + stat.withArgs(primary).rejects(error); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'unusable', executable: primary, error }); + sinon.assert.notCalled(run); + }); + + test('tries an alternative after denied access to a PATH candidate', async () => { + enableAlternatives(); + stat.withArgs(primary).rejects(Object.assign(new Error('access denied'), { code: 'EACCES' })); + known(alternatives[1]); + assert.deepStrictEqual(await subject.detectPymanager(), { + kind: 'available', + executable: alternatives[1], + }); + }); + + test('treats a known but unresolvable executable as unusable', async () => { + known(primary); + resolveExecutable.resolves(null); + assert.strictEqual((await subject.detectPymanager()).kind, 'unusable'); + sinon.assert.notCalled(run); + }); + + test('does not launch a different filename returned by which', async () => { + known(primary); + resolveExecutable.resolves(path.join(pathDirectory, 'pymanager.exe.cmd')); + assert.strictEqual((await subject.detectPymanager()).kind, 'unusable'); + sinon.assert.notCalled(run); + }); + + test('rejects a directory named pymanager.exe as unusable', async () => { + stat.withArgs(primary).resolves({ isFile: () => false, isSymbolicLink: () => false }); + assert.strictEqual((await subject.detectPymanager()).kind, 'unusable'); + sinon.assert.notCalled(run); + }); + + test('skips empty, relative and current-directory PATH entries, accepting quoted absolute entries', async () => { + process.env.PATH = ['', '.', 'relative', process.cwd(), `"${pathDirectory}"`].join(path.delimiter); + known(primary); + assert.strictEqual((await subject.detectPymanager()).kind, 'available'); + sinon.assert.calledOnceWithExactly(stat, primary); + sinon.assert.calledOnceWithExactly(resolveExecutable, primary, { nothrow: true, pathExt: '.EXE' }); + }); + + test('skips relative or absent alternative roots rather than probing the current directory', async () => { + process.env.PATH = ''; + process.env.LOCALAPPDATA = 'relative'; + process.env.ProgramFiles = ''; + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'absent' }); + sinon.assert.notCalled(stat); + sinon.assert.notCalled(run); + }); + + test('deduplicates resolved paths case-insensitively without changing the reported path', async () => { + const first = path.join(pathDirectory.toUpperCase(), 'pymanager.exe'); + process.env.PATH = [pathDirectory.toUpperCase(), path.join(pathDirectory, '.'), pathDirectory].join(path.delimiter); + known(first); + run.rejects(new InstallerProcessError('exit', 1)); + const result = await subject.detectPymanager(); + assert.strictEqual(result.kind, 'unusable'); + assert.strictEqual(Uri.file(result.executable).fsPath, Uri.file(first).fsPath); + sinon.assert.calledOnceWithExactly(stat, first); + sinon.assert.calledOnce(run); + }); + + test('deduplicates documented alternatives that also appear in PATH', async () => { + enableAlternatives(); + process.env.PATH = path.dirname(alternatives[0]); + known(alternatives[0]); + run.rejects(new InstallerProcessError('exit', 1)); + await subject.detectPymanager(); + assert.strictEqual(stat.withArgs(alternatives[0]).callCount, 1); + sinon.assert.calledOnce(run); + }); + + test('coalesces concurrent probes while allowing later retries', async () => { + known(primary); + let complete: (result: InstallerProcessResult) => void = () => assert.fail('Probe was not initialized'); + run.onFirstCall().returns( + new Promise((resolve) => { + complete = resolve; + }), + ); + const first = subject.detectPymanager(); + const second = subject.detectPymanager(); + await clock.tickAsync(0); + sinon.assert.calledOnce(run); + complete({ stdout: output(), stderr: '' }); + const results = await Promise.all([first, second]); + assert.deepStrictEqual(results, [ + { kind: 'available', executable: primary }, + { kind: 'available', executable: primary }, + ]); + await subject.detectPymanager(); + assert.strictEqual(run.callCount, 2); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('does not cache absence after a later explicit attempt', async () => { + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'absent' }); + known(primary); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'available', executable: primary }); + sinon.assert.calledOnce(run); + }); + + test('retries a previously unusable manager on a later explicit attempt', async () => { + known(primary); + run.onFirstCall().rejects(new InstallerProcessError('exit', 1)); + assert.strictEqual((await subject.detectPymanager()).kind, 'unusable'); + assert.deepStrictEqual(await subject.detectPymanager(), { kind: 'available', executable: primary }); + assert.strictEqual(run.callCount, 2); + }); + + test('bounds stalled filesystem lookup, reports uncertainty instead of absence and releases its timer', async () => { + stat.withArgs(primary).returns(new Promise(() => undefined)); + const pending = subject.detectPymanager(); + await clock.tickAsync(1000); + const result = await pending; + assert.strictEqual(result.kind, 'unusable'); + if (result.kind === 'unusable') { + assert.ok(result.error instanceof InstallerProcessError); + assert.strictEqual(result.error.kind, 'timeout'); + assert.strictEqual(result.error.processMayStillBeRunning, false); + } + sinon.assert.notCalled(run); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('bounds total detection time across repeatedly failing candidates', async () => { + process.env.PATH = Array.from({ length: 20 }, (_value, index) => path.join(fixtureRoot, `candidate-${index}`)).join( + path.delimiter, + ); + stat.resolves({ isFile: () => true, isSymbolicLink: () => false }); + run.callsFake( + async () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new InstallerProcessError('timeout', undefined, true)), 3000); + }), + ); + const pending = subject.detectPymanager(); + await clock.tickAsync(15000); + assert.strictEqual((await pending).kind, 'unusable'); + assert.strictEqual(run.callCount, 5); + assert.strictEqual(clock.countTimers(), 0); + }); +}); diff --git a/src/test/managers/builtin/pythonInstaller.unit.test.ts b/src/test/managers/builtin/pythonInstaller.unit.test.ts new file mode 100644 index 000000000..20f8eaaea --- /dev/null +++ b/src/test/managers/builtin/pythonInstaller.unit.test.ts @@ -0,0 +1,1179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { + CancellationError, + CancellationToken, + CancellationTokenSource, + LogOutputChannel, + Progress, + ProgressOptions, + Uri, +} from 'vscode'; +import * as processes from '../../../common/installerProcess'; +import { InstallerProcessError, InstallerProcessOptions } from '../../../common/installerProcess'; +import { Common, PythonInstallStrings } from '../../../common/localize'; +import * as logging from '../../../common/logging'; +import * as persistentState from '../../../common/persistentState'; +import { PythonVersion } from '../../../common/pythonVersion'; +import * as telemetry from '../../../common/telemetry/sender'; +import { createDeferred, Deferred } from '../../../common/utils/deferred'; +import * as platform from '../../../common/utils/platformUtils'; +import * as windowApis from '../../../common/window.apis'; +import * as pymanager from '../../../managers/builtin/pymanagerPythonInstaller'; +import { PymanagerListOptions, PymanagerRuntime } from '../../../managers/builtin/pymanagerPythonInstaller'; +import { + PythonInstallationError, + PythonInstallFailure, + PythonInstallResult, +} from '../../../managers/builtin/pythonInstallerTypes'; +import * as candidates from '../../../managers/builtin/pythonRuntimeCandidates'; +import { PymanagerCandidate } from '../../../managers/builtin/pythonRuntimeCandidates'; +import * as uv from '../../../managers/builtin/uvPythonInstaller'; +import * as watchers from '../../../managers/common/packageWatcher'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +interface RuntimeFixture { + readonly runtime: PymanagerRuntime; + readonly metadataFile: string; +} + +function onlineRuntime(version = '3.14.6', overrides: Partial = {}): PymanagerRuntime { + const family = version.split('.').slice(0, 2).join('.'); + return { + id: `pythoncore-${family}-64`, + company: 'PythonCore', + version, + tag: `${family}-64`, + installTags: [`${family}-64`, `${version}-64`], + executable: 'python.exe', + executableArgs: [], + unmanaged: false, + ...overrides, + }; +} + +function uvRuntime(version: string, overrides: Partial = {}): uv.UvPythonVersion { + const parsed = new PythonVersion(version); + return { + key: `cpython-${version}`, + version, + version_parts: { major: parsed.major, minor: parsed.minor, patch: parsed.patch }, + path: null, + url: null, + os: 'windows', + variant: 'default', + implementation: 'cpython', + arch: 'x86_64', + ...overrides, + }; +} + +suite('pythonInstaller', () => { + const modulePath = require.resolve('../../../managers/builtin/pythonInstaller'); + let installer: typeof import('../../../managers/builtin/pythonInstaller'); + let previousModule: NodeJS.Module | undefined; + let sandbox: sinon.SinonSandbox; + let root: string; + let managerExecutable: string; + let fixtureNumber: number; + let metadataFiles: Map; + let log: LogOutputChannel; + let progressToken: CancellationTokenSource; + let tokens: CancellationTokenSource[]; + let gates: Deferred[]; + let releaseChoices: (() => void)[]; + let pending: Promise[]; + let nativeInstall: ((args: readonly string[], options?: InstallerProcessOptions) => Promise) | undefined; + let detect: sinon.SinonStub; + let list: sinon.SinonStub; + let run: sinon.SinonStub; + let resolveCandidate: sinon.SinonStub; + let information: sinon.SinonStub; + let errors: sinon.SinonStub; + let quickPick: sinon.SinonStub; + let progress: sinon.SinonStub; + let pauseWatchers: sinon.SinonStub; + let optOut: sinon.SinonStub; + let setState: sinon.SinonStub; + let uvPrompt: sinon.SinonStub; + let uvInlinePreflight: sinon.SinonStub; + let uvCatalogue: sinon.SinonStub; + let uvPicker: sinon.SinonStub; + let uvInstall: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + root = path.join(process.cwd(), 'out', `python-installer-unit-${process.pid}-${randomUUID()}`); + managerExecutable = path.join(root, 'pymanager.exe'); + fixtureNumber = 0; + metadataFiles = new Map(); + log = createMockLogOutputChannel(); + progressToken = new CancellationTokenSource(); + tokens = [progressToken]; + gates = []; + releaseChoices = []; + pending = []; + nativeInstall = undefined; + + sandbox.stub(platform, 'isWindows').returns(true); + sandbox.stub(process, 'arch').value('x64'); + sandbox.stub(logging, 'traceError'); + sandbox.stub(logging, 'traceWarn'); + sandbox.stub(logging, 'traceInfo'); + sandbox.stub(telemetry, 'sendTelemetryEvent'); + detect = sandbox.stub(pymanager, 'detectPymanager').resolves({ + kind: 'available', executable: managerExecutable, + }); + list = sandbox.stub(pymanager, 'listPymanagerRuntimes').resolves([]); + resolveCandidate = sandbox.stub(candidates, 'resolvePymanagerCandidate').resolves(candidate()); + run = sandbox.stub(processes, 'runInstallerProcess'); + run.callsFake(async (command: string, args: readonly string[], options?: InstallerProcessOptions) => { + if (options?.cancellationToken?.isCancellationRequested) { + throw new CancellationError(); + } + if (command === managerExecutable && args[0] === 'install') { + await nativeInstall?.(args, options); + return { stdout: '', stderr: '' }; + } + const metadataFile = metadataFiles.get(command); + assert.ok(metadataFile, 'Only owned inert fixture executables may be probed'); + assert.deepStrictEqual(args.slice(0, 3), ['-I', '-S', '-c']); + assert.match(args[3], /sys\.base_prefix/); + assert.match(args[3], /python_implementation/); + return { stdout: await fs.readFile(metadataFile, 'utf8'), stderr: '' }; + }); + information = sandbox.stub(windowApis, 'showInformationMessage').resolves(undefined); + errors = sandbox.stub(windowApis, 'showErrorMessage').resolves(undefined); + quickPick = sandbox.stub(windowApis, 'showQuickPick').resolves(undefined); + progress = sandbox.stub(windowApis, 'withProgress'); + progress.callsFake(async ( + _options: ProgressOptions, + action: (value: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => Thenable, + ) => action({ report: () => undefined }, progressToken.token)); + pauseWatchers = sandbox.stub(watchers, 'withPackageWatchersPaused'); + pauseWatchers.callsFake(async (_prefix: string, operation: () => Promise) => operation()); + + optOut = sandbox.stub(uv, 'isDontAskAgainSet').resolves(false); + uvPrompt = sandbox.stub(uv, 'promptInstallPythonViaUvDetailed').resolves({ kind: 'declined' }); + uvInlinePreflight = sandbox.stub(uv, 'ensureUvForInlineScriptVersionLookupDetailed').resolves('available'); + uvCatalogue = sandbox.stub(uv, 'getAvailablePythonVersions').resolves([]); + uvPicker = sandbox.stub(uv, 'selectPythonVersionToInstall').resolves(undefined); + uvInstall = sandbox.stub(uv, 'installPythonWithUv').resolves(undefined); + setState = sandbox.stub().resolves(); + sandbox.stub(persistentState, 'getGlobalPersistentState').resolves({ + get: sandbox.stub().resolves(undefined), + set: setState, + clear: sandbox.stub().resolves(), + }); + + // Each test owns its coordinator queue; other suites retain their module instance. + previousModule = require.cache[modulePath]; + delete require.cache[modulePath]; + installer = require(modulePath); + }); + + teardown(async () => { + for (const release of releaseChoices) { + release(); + } + for (const gate of gates) { + gate.resolve(); + } + await Promise.allSettled(pending); + for (const token of tokens) { + token.dispose(); + } + if (previousModule) { + require.cache[modulePath] = previousModule; + } else { + delete require.cache[modulePath]; + } + sandbox.restore(); + await fs.rm(root, { recursive: true, force: true }); + }); + + function candidate(version = '3.14.6'): PymanagerCandidate { + const value = candidates.toPymanagerCandidate(onlineRuntime(version), 'x64'); + assert.ok(value); + return value; + } + + function provider() { + return { + kind: 'pymanager' as const, + executable: managerExecutable, + }; + } + + async function fixture(version = '3.14.6'): Promise { + const directory = path.join(root, `runtime-${++fixtureNumber}`); + await fs.mkdir(directory, { recursive: true }); + const prefix = await fs.realpath(directory); + const executable = path.join(prefix, 'python.exe'); + await fs.writeFile(executable, 'Inert unit-test data. This file must never be executed.'); + const result = { + runtime: onlineRuntime(version, { prefix, executable }), + metadataFile: path.join(prefix, 'interpreter-metadata.json'), + }; + metadataFiles.set(executable, result.metadataFile); + await writeMetadata(result); + return result; + } + + async function writeMetadata(value: RuntimeFixture, overrides: Record = {}): Promise { + await fs.writeFile(value.metadataFile, JSON.stringify({ + executable: value.runtime.executable, + prefix: value.runtime.prefix, + basePrefix: value.runtime.prefix, + version: value.runtime.version, + implementation: 'CPython', + ...overrides, + })); + } + + function nativeCalls(): sinon.SinonSpyCall[] { + return run.getCalls().filter((call) => call.args[0] === managerExecutable && call.args[1]?.[0] === 'install'); + } + + function assertNoUv(): void { + for (const stub of [uvPrompt, uvInlinePreflight, uvCatalogue, uvPicker, uvInstall]) { + sinon.assert.notCalled(stub); + } + } + + function assertFailure(result: PythonInstallResult, reason: PythonInstallFailure): void { + assert.strictEqual(result.kind, 'failed'); + assert.strictEqual(result.reason, reason); + } + + function verificationFailure(error: unknown): boolean { + assert.ok(error instanceof PythonInstallationError); + assert.strictEqual(error.reason, 'verification-failed'); + return true; + } + + function approve(version = '3.14.6'): void { + information.resolves(PythonInstallStrings.installAction(version)); + } + + function gate(): Deferred { + const value = createDeferred(); + gates.push(value); + return value; + } + + function track(operation: Promise): Promise { + pending.push(operation); + return operation; + } + + async function entered(value: Deferred, operation: Promise): Promise { + await Promise.race([ + value.promise, + operation.then(() => assert.fail('Operation completed before reaching the expected blocked stage')), + ]); + } + + suite('provider selection and opt-out', () => { + test('uses UV outside Windows without probing PyManager', async () => { + (platform.isWindows as sinon.SinonStub).returns(false); + assert.deepStrictEqual(await installer.getPythonInstaller(), { kind: 'uv' }); + sinon.assert.notCalled(detect); + sinon.assert.notCalled(list); + sinon.assert.notCalled(run); + }); + + test('prefers an available Windows PyManager', async () => { + assert.deepStrictEqual(await installer.getPythonInstaller(), provider()); + sinon.assert.calledOnceWithExactly(detect); + assertNoUv(); + }); + + test('uses UV when Windows PyManager is absent', async () => { + detect.resolves({ kind: 'absent' }); + assert.deepStrictEqual(await installer.getPythonInstaller(), { kind: 'uv' }); + sinon.assert.calledOnce(detect); + }); + + test('does not fall back to UV when PyManager is known but unusable', async () => { + detect.resolves({ kind: 'unusable', executable: managerExecutable, error: new Error('denied') }); + await assert.rejects(installer.getPythonInstaller(), (error: unknown) => { + assert.ok(error instanceof PythonInstallationError); + assert.strictEqual(error.reason, 'provider-unusable'); + return true; + }); + assertNoUv(); + sinon.assert.notCalled(list); + }); + + for (const trigger of ['activation', 'createEnvironment'] as const) { + test(`honors the legacy opt-out before ${trigger} provider discovery`, async () => { + optOut.resolves(true); + assert.deepStrictEqual(await installer.promptInstallPythonDetailed(trigger, log), { kind: 'declined' }); + sinon.assert.notCalled(detect); + sinon.assert.notCalled(information); + sinon.assert.notCalled(run); + assertNoUv(); + }); + } + + test('does not apply legacy opt-out or offer it to an explicit inline-script request', async () => { + optOut.resolves(true); + const result = await installer.promptInstallPythonDetailed('inlineScript', log, { version: '3.14.6' }); + assert.deepStrictEqual(result, { kind: 'declined' }); + sinon.assert.notCalled(optOut); + sinon.assert.calledOnce(detect); + assert.deepStrictEqual(information.firstCall.args.slice(1), [ + { modal: true }, PythonInstallStrings.installAction('3.14.6'), + ]); + sinon.assert.notCalled(setState); + assertNoUv(); + }); + + test('persists the shared legacy opt-out without installing anything', async () => { + information.resolves(Common.dontAskAgain); + assert.deepStrictEqual(await installer.promptInstallPythonDetailed('activation', log), { kind: 'declined' }); + sinon.assert.calledOnceWithExactly(setState, uv.UV_INSTALL_PYTHON_DONT_ASK_KEY, true); + assert.ok(information.firstCall.args.includes(Common.dontAskAgain)); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('rejects an invalid request before opt-out, discovery, catalogue or prompt', async () => { + const result = await installer.promptInstallPythonDetailed('inlineScript', log, { version: '3.x' }); + assertFailure(result, 'no-compatible-python'); + sinon.assert.notCalled(optOut); + sinon.assert.notCalled(detect); + sinon.assert.notCalled(resolveCandidate); + sinon.assert.notCalled(information); + sinon.assert.notCalled(errors); + }); + + for (const trigger of ['activation', 'inlineScript'] as const) { + test(`reports an unusable manager correctly for ${trigger} without trying UV`, async () => { + detect.resolves({ kind: 'unusable', executable: managerExecutable, error: new Error('unusable') }); + const result = await installer.promptInstallPythonDetailed(trigger, log); + assertFailure(result, 'provider-unusable'); + assert.strictEqual(result.kind === 'failed' && result.alreadyReported, trigger !== 'inlineScript'); + assert.strictEqual(errors.callCount, trigger === 'inlineScript' ? 0 : 1); + assertNoUv(); + }); + } + }); + + suite('UV delegation', () => { + for (const availability of ['non-Windows', 'absent'] as const) { + test(`delegates to the existing UV backend when ${availability}`, async () => { + if (availability === 'non-Windows') { + (platform.isWindows as sinon.SinonStub).returns(false); + } else { + detect.resolves({ kind: 'absent' }); + } + const value = await fixture(); + uvPrompt.resolves({ kind: 'installed', pythonPath: value.runtime.executable }); + const result = await installer.promptInstallPythonDetailed('createEnvironment', log, { version: '3.14.6' }); + assert.deepStrictEqual(result, { + kind: 'installed', provider: 'uv', pythonPath: value.runtime.executable, + }); + sinon.assert.calledOnceWithExactly(uvPrompt, 'createEnvironment', log, { + version: '3.14.6', requiresPython: undefined, + }); + sinon.assert.notCalled(list); + sinon.assert.notCalled(run); + }); + } + + test('preserves UV decline and maps UV failure without retrying another provider', async () => { + detect.resolves({ kind: 'absent' }); + uvPrompt.onFirstCall().resolves({ kind: 'declined' }); + uvPrompt.onSecondCall().resolves({ kind: 'failed' }); + assert.deepStrictEqual(await installer.promptInstallPythonDetailed('activation', log), { kind: 'declined' }); + assert.deepStrictEqual(await installer.promptInstallPythonDetailed('activation', log), { + kind: 'failed', reason: 'install-failed', + }); + sinon.assert.notCalled(list); + sinon.assert.notCalled(run); + }); + + test('passes a simple inline-script lower bound to UV without a catalogue preflight', async () => { + detect.resolves({ kind: 'absent' }); + await installer.promptInstallPythonDetailed('inlineScript', log, { requiresPython: '>=3.13' }); + sinon.assert.calledOnceWithExactly(uvPrompt, 'inlineScript', log, { + version: '3.13', requiresPython: '>=3.13', + }); + sinon.assert.notCalled(uvInlinePreflight); + sinon.assert.notCalled(uvCatalogue); + sinon.assert.notCalled(optOut); + }); + + test('resolves short exact equality against all UV patch versions, not a family selector', async () => { + detect.resolves({ kind: 'absent' }); + uvCatalogue.resolves([uvRuntime('3.13.12'), uvRuntime('3.13.0')]); + await installer.promptInstallPythonDetailed('inlineScript', log, { requiresPython: '==3.13' }); + sinon.assert.calledOnceWithExactly(uvInlinePreflight, '==3.13', log); + sinon.assert.calledOnceWithExactly(uvCatalogue, { allVersions: true }); + sinon.assert.calledOnceWithExactly(uvPrompt, 'inlineScript', log, { + version: '3.13.0', requiresPython: '==3.13', + }); + sinon.assert.callOrder(uvInlinePreflight, uvCatalogue, uvPrompt); + }); + + test('filters a compound UV catalogue before asking to install the selected release', async () => { + detect.resolves({ kind: 'absent' }); + uvCatalogue.resolves([ + uvRuntime('3.13.8'), uvRuntime('3.14.0'), + uvRuntime('3.13.20', { implementation: 'pypy' }), + uvRuntime('3.13.19', { variant: 'freethreaded' }), + uvRuntime('3.13.12'), + ]); + await installer.promptInstallPythonDetailed('inlineScript', log, { requiresPython: '>=3.13,<3.14' }); + assert.strictEqual(uvPrompt.firstCall.args[2].version, '3.13.12'); + }); + + for (const result of ['declined', 'failed'] as const) { + test(`does not query or install a UV runtime after inline preflight ${result}`, async () => { + detect.resolves({ kind: 'absent' }); + uvInlinePreflight.resolves(result); + const actual = await installer.promptInstallPythonDetailed('inlineScript', log, { + requiresPython: '>=3.13,<3.14', + }); + assert.strictEqual(actual.kind, result); + sinon.assert.notCalled(uvCatalogue); + sinon.assert.notCalled(uvPrompt); + sinon.assert.notCalled(run); + }); + } + + test('does not offer installation when the UV catalogue is empty', async () => { + detect.resolves({ kind: 'absent' }); + uvCatalogue.resolves([]); + const result = await installer.promptInstallPythonDetailed('inlineScript', log, { + requiresPython: '>=3.13,<3.14', + }); + assertFailure(result, 'catalogue-failed'); + sinon.assert.notCalled(uvPrompt); + }); + }); + + suite('actual base-interpreter verification', () => { + test('reads metadata from an inert owned file and verifies its canonical executable and base prefix', async () => { + const value = await fixture(); + const result = await installer.verifyPymanagerRuntime(value.runtime, { version: '3.14.6' }); + assert.strictEqual(Uri.file(result.executable).fsPath, Uri.file(await fs.realpath(value.runtime.executable)).fsPath); + assert.strictEqual(result.version.toString(), '3.14.6'); + sinon.assert.calledOnce(run); + assert.strictEqual(run.firstCall.args[2].timeoutMs, 30000); + assert.strictEqual(nativeCalls().length, 0); + }); + + const invalidRuntimes: readonly [string, Partial][] = [ + ['unmanaged', { unmanaged: true }], + ['missing prefix', { prefix: undefined }], + ['relative prefix', { prefix: 'relative-prefix' }], + ['relative executable', { executable: 'python.exe' }], + ['wrong company', { company: 'Other' }], + ['wrong architecture', { tag: '3.14-arm64' }], + ['required launch arguments', { executableArgs: ['-X', 'special'] }], + ['wrong declared version', { version: '3.14.5' }], + ]; + + for (const [name, overrides] of invalidRuntimes) { + test(`rejects ${name} before running the interpreter probe`, async () => { + const value = await fixture(); + await assert.rejects( + installer.verifyPymanagerRuntime({ ...value.runtime, ...overrides }, { version: '3.14.6' }), + verificationFailure, + ); + sinon.assert.notCalled(run); + }); + } + + test('rejects an executable that does not exist', async () => { + const value = await fixture(); + await fs.unlink(value.runtime.executable); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + sinon.assert.notCalled(run); + }); + + test('rejects a directory named python.exe', async () => { + const value = await fixture(); + await fs.unlink(value.runtime.executable); + await fs.mkdir(value.runtime.executable); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + sinon.assert.notCalled(run); + }); + + test('rejects a pyvenv.cfg marker rather than accepting an active virtual environment as a base', async () => { + const value = await fixture(); + await fs.writeFile(path.join(value.runtime.prefix!, 'pyvenv.cfg'), 'home = test fixture'); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + sinon.assert.notCalled(run); + }); + + for (const text of ['not JSON', '[]', 'null', '{}']) { + test(`rejects interpreter metadata ${JSON.stringify(text)}`, async () => { + const value = await fixture(); + await fs.writeFile(value.metadataFile, text); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + }); + } + + const invalidMetadata: readonly [string, Record][] = [ + ['wrong implementation', { implementation: 'PyPy' }], + ['wrong actual version', { version: '3.14.5' }], + ['invalid version', { version: 'unknown' }], + ['missing version', { version: undefined }], + ['relative executable', { executable: 'python.exe' }], + ['relative prefix', { prefix: 'relative' }], + ['relative base prefix', { basePrefix: 'relative' }], + ['non-string executable', { executable: 3 }], + ['non-string prefix', { prefix: null }], + ['missing base prefix', { basePrefix: undefined }], + ]; + + for (const [name, overrides] of invalidMetadata) { + test(`rejects ${name} in interpreter metadata`, async () => { + const value = await fixture(); + await writeMetadata(value, overrides); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + }); + } + + for (const field of ['executable', 'prefix', 'basePrefix'] as const) { + test(`rejects an existing but different canonical ${field}`, async () => { + const value = await fixture(); + const other = await fixture(); + await writeMetadata(value, { + [field]: field === 'executable' ? other.runtime.executable : other.runtime.prefix, + }); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + }); + } + + test('classifies a missing metadata-reported path as verification failure', async () => { + const value = await fixture(); + await writeMetadata(value, { executable: path.join(root, 'missing-python.exe') }); + await assert.rejects(installer.verifyPymanagerRuntime(value.runtime, {}), verificationFailure); + }); + + test('preserves cancellation and passes the token to the isolated interpreter probe', async () => { + const value = await fixture(); + progressToken.cancel(); + await assert.rejects( + installer.verifyPymanagerRuntime(value.runtime, {}, progressToken.token), + CancellationError, + ); + assert.strictEqual(run.firstCall.args[2].cancellationToken, progressToken.token); + assert.strictEqual(await fs.readFile(value.runtime.executable, 'utf8'), 'Inert unit-test data. This file must never be executed.'); + }); + }); + + suite('consent, reuse and installation', () => { + test('reuses an independently verified matching managed Python without querying online or prompting', async () => { + const value = await fixture(); + list.resolves([{ ...value.runtime, installTags: [] }]); + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14' }); + assert.deepStrictEqual(result, { kind: 'installed', provider: 'pymanager', pythonPath: await fs.realpath(value.runtime.executable) }); + sinon.assert.notCalled(resolveCandidate); + sinon.assert.notCalled(information); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('tries another compatible installed runtime when the newest fails verification', async () => { + const bad = await fixture('3.14.7'); + const good = await fixture('3.13.12'); + await writeMetadata(bad, { version: '3.14.5' }); + list.resolves([good.runtime, bad.runtime]); + const result = await installer.promptInstallPythonDetailed('activation', log); + assert.strictEqual(result.kind === 'installed' && result.pythonPath, await fs.realpath(good.runtime.executable)); + assert.strictEqual(run.firstCall.args[0], bad.runtime.executable); + assert.strictEqual(run.secondCall.args[0], good.runtime.executable); + assert.strictEqual(nativeCalls().length, 0); + sinon.assert.notCalled(information); + }); + + test('does not silently install or fall back when a matching reported base is unusable', async () => { + const value = await fixture(); + await fs.writeFile(path.join(value.runtime.prefix!, 'pyvenv.cfg'), ''); + list.resolves([value.runtime]); + assertFailure(await installer.promptInstallPythonDetailed('activation', log), 'verification-failed'); + sinon.assert.notCalled(resolveCandidate); + sinon.assert.notCalled(information); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('declines without mutation when consent is dismissed', async () => { + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assert.deepStrictEqual(result, { kind: 'declined' }); + assert.strictEqual(list.callCount, 1); + assert.strictEqual(nativeCalls().length, 0); + sinon.assert.notCalled(errors); + assertNoUv(); + }); + + test('waits for informative install/update consent before submitting one concrete native tag', async () => { + const value = await fixture(); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([value.runtime]); + const choice = createDeferred(); + releaseChoices.push(() => choice.resolve(undefined)); + const prompted = gate(); + information.onFirstCall().callsFake(() => { + prompted.resolve(); + return choice.promise; + }); + const operation = track(installer.promptInstallPythonDetailed('inlineScript', log, { + requiresPython: '>=3.14,<3.15', + })); + await entered(prompted, operation); + assert.strictEqual(nativeCalls().length, 0); + const message: string = information.firstCall.args[0]; + assert.match(message, /Python Install Manager/); + assert.match(message, /older.*runtime.*updated/i); + assert.match(message, /same version family/i); + assert.match(message, /affecting environments/i); + assert.match(message, /newer runtimes will not be downgraded/i); + assert.match(message, />=3\.14,<3\.15/); + assert.deepStrictEqual(information.firstCall.args.slice(1), [ + { modal: true }, PythonInstallStrings.installAction('3.14.6'), + ]); + choice.resolve(PythonInstallStrings.installAction('3.14.6')); + const result = await operation; + assert.strictEqual(result.kind, 'installed'); + assert.strictEqual(nativeCalls().length, 1); + assert.deepStrictEqual(nativeCalls()[0].args[1], ['install', '--update', '--yes', 'PythonCore\\3.14.6-64']); + assert.strictEqual(nativeCalls()[0].args[2].timeoutMs, 300000); + assert.strictEqual(nativeCalls()[0].args[2].cancellationToken, progressToken.token); + assert.ok(information.firstCall.calledBefore(nativeCalls()[0])); + assert.ok(nativeCalls()[0].calledBefore(run.lastCall)); + assert.strictEqual(information.callCount, 1, 'Inline caller owns any completion notification'); + assertNoUv(); + }); + + test('updates an approved older slot and pauses watchers for that same canonical base', async () => { + const old = await fixture('3.14.5'); + const updated = { ...old.runtime, version: '3.14.6', installTags: ['3.14.6-64'] }; + let installed = [old.runtime]; + list.callsFake(async () => installed); + approve(); + nativeInstall = async () => { + assert.strictEqual(pauseWatchers.callCount, 1); + await writeMetadata(old, { version: '3.14.6' }); + installed = [updated]; + }; + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assert.strictEqual(result.kind, 'installed'); + assert.strictEqual(pauseWatchers.firstCall.args[0], await fs.realpath(old.runtime.prefix!)); + assert.deepStrictEqual(nativeCalls()[0].args[1], ['install', '--update', '--yes', 'PythonCore\\3.14.6-64']); + assert.ok(information.firstCall.calledBefore(pauseWatchers.firstCall)); + assertNoUv(); + }); + + test('refuses a pre-existing newer incompatible slot before displaying install consent', async () => { + const newer = await fixture('3.14.7'); + list.resolves([newer.runtime]); + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assertFailure(result, 'runtime-conflict'); + sinon.assert.notCalled(information); + assert.strictEqual(nativeCalls().length, 0); + assert.match(errors.firstCall.args[0], /3\.14\.7/); + assert.match(errors.firstCall.args[0], /3\.14\.6/); + assertNoUv(); + }); + + test('rechecks the slot after consent and refuses a newer incompatible runtime that raced in', async () => { + const newer = await fixture('3.14.7'); + list.onCall(0).resolves([]); + list.onCall(1).resolves([newer.runtime]); + approve(); + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assertFailure(result, 'runtime-conflict'); + assert.strictEqual(information.callCount, 1); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('uses update-only semantics when a newer slot appears after preflight, then enforces the exact request', async () => { + const newer = await fixture('3.14.7'); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([newer.runtime]); + approve(); + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assertFailure(result, 'runtime-conflict'); + assert.deepStrictEqual(nativeCalls()[0].args[1], ['install', '--update', '--yes', 'PythonCore\\3.14.6-64']); + assert.strictEqual(nativeCalls().length, 1); + assert.strictEqual(await fs.readFile(newer.runtime.executable, 'utf8'), 'Inert unit-test data. This file must never be executed.'); + assertNoUv(); + }); + + test('accepts a newer racing slot when its actual interpreter still satisfies the family request', async () => { + const newer = await fixture('3.14.7'); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([newer.runtime]); + approve(); + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14' }); + assert.strictEqual(result.kind === 'installed' && result.pythonPath, await fs.realpath(newer.runtime.executable)); + assert.strictEqual(nativeCalls().length, 1); + assert.ok(nativeCalls()[0].args[1].includes('--update')); + }); + + test('does not treat native success as sufficient when the installed slot is absent', async () => { + approve(); + assertFailure( + await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }), + 'verification-failed', + ); + assert.strictEqual(nativeCalls().length, 1); + assertNoUv(); + }); + + test('rejects native success when the published executable path is missing', async () => { + const value = await fixture(); + await fs.unlink(value.runtime.executable); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([value.runtime]); + approve(); + assertFailure( + await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }), + 'verification-failed', + ); + assert.strictEqual(nativeCalls().length, 1); + assert.strictEqual(run.callCount, 1, 'The missing path must not be launched for verification'); + assertNoUv(); + }); + + test('rejects a post-install interpreter whose actual version differs from the installed record', async () => { + const value = await fixture(); + await writeMetadata(value, { version: '3.14.5' }); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([value.runtime]); + approve(); + assertFailure( + await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }), + 'verification-failed', + ); + assert.strictEqual(nativeCalls().length, 1); + assertNoUv(); + }); + + test('rejects a post-install virtual environment instead of returning it as a base Python', async () => { + const value = await fixture(); + await fs.writeFile(path.join(value.runtime.prefix!, 'pyvenv.cfg'), ''); + list.onCall(0).resolves([]); + list.onCall(1).resolves([]); + list.onCall(2).resolves([value.runtime]); + approve(); + assertFailure( + await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }), + 'verification-failed', + ); + assert.strictEqual(nativeCalls().length, 1); + assert.strictEqual(run.callCount, 1, 'A non-base path must be rejected before its interpreter probe'); + }); + + test('maps the convenience API to a path only for a verified installed outcome', async () => { + const value = await fixture(); + list.resolves([value.runtime]); + assert.strictEqual(await installer.promptInstallPython('activation', log), await fs.realpath(value.runtime.executable)); + list.resolves([]); + assert.strictEqual(await installer.promptInstallPython('activation', log), undefined); + }); + + }); + + suite('errors and cancellation', () => { + test('does not start UV or prompt when the installed-runtime inventory fails', async () => { + list.rejects(new InstallerProcessError('exit', 1)); + assertFailure(await installer.promptInstallPythonDetailed('activation', log), 'provider-unusable'); + sinon.assert.notCalled(resolveCandidate); + sinon.assert.notCalled(information); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('classifies native catalogue errors separately from no compatible runtime', async () => { + resolveCandidate.rejects(new InstallerProcessError('exit', 1)); + assertFailure(await installer.promptInstallPythonDetailed('activation', log), 'catalogue-failed'); + sinon.assert.calledOnceWithExactly(errors, PythonInstallStrings.catalogueFailed); + sinon.assert.notCalled(information); + assertNoUv(); + }); + + test('returns a distinct no-compatible outcome without consent or native mutation', async () => { + resolveCandidate.resolves(undefined); + assertFailure(await installer.promptInstallPythonDetailed('inlineScript', log), 'no-compatible-python'); + sinon.assert.notCalled(information); + sinon.assert.notCalled(errors); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + for (const trigger of ['activation', 'inlineScript'] as const) { + test(`preserves native install cancellation for ${trigger} without starting an alternate installer`, async () => { + approve(); + nativeInstall = async () => { + progressToken.cancel(); + throw new CancellationError(); + }; + const result = await installer.promptInstallPythonDetailed(trigger, log, { version: '3.14.6' }); + assert.strictEqual(result.kind, 'cancelled'); + assert.strictEqual(result.kind === 'cancelled' && result.message, PythonInstallStrings.cancelled); + assert.strictEqual(nativeCalls().length, 1); + assert.strictEqual(list.callCount, 2, 'Cancellation must not trigger a post-install command'); + sinon.assert.notCalled(errors); + assert.strictEqual(information.callCount, trigger === 'inlineScript' ? 1 : 2); + assertNoUv(); + }); + } + + test('keeps a runtime already published before cancellation rather than deleting it or falling back', async () => { + const value = await fixture(); + approve(); + nativeInstall = async () => { + list.resolves([value.runtime]); + throw new CancellationError(); + }; + const result = await installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' }); + assert.strictEqual(result.kind, 'cancelled'); + assert.strictEqual(await fs.readFile(value.runtime.executable, 'utf8'), 'Inert unit-test data. This file must never be executed.'); + assert.strictEqual(list.callCount, 2, 'Cancellation must not start another native inventory command'); + assert.strictEqual(nativeCalls().length, 1); + assertNoUv(); + }); + + test('stops an already-cancelled install before its preflight inventory or native mutation', async () => { + const mutation = sandbox.spy(async () => undefined); + nativeInstall = mutation; + progress.callsFake(async ( + options: ProgressOptions, + action: (value: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => Thenable, + ) => { + if (options.cancellable) { + progressToken.cancel(); + } + return action({ report: () => undefined }, progressToken.token); + }); + approve(); + assert.strictEqual((await installer.promptInstallPythonDetailed('activation', log)).kind, 'cancelled'); + assert.strictEqual(list.callCount, 1, 'Only the inventory before consent should have been queried'); + sinon.assert.notCalled(run); + sinon.assert.notCalled(pauseWatchers); + sinon.assert.notCalled(mutation); + assertNoUv(); + }); + + test('does not start post-install inventory when cancellation arrives as installation completes', async () => { + const value = await fixture(); + list.onCall(2).resolves([value.runtime]); + approve(); + nativeInstall = async () => { progressToken.cancel(); }; + + const result = await installer.promptInstallPythonDetailed('inlineScript', log, { version: '3.14.6' }); + + assert.strictEqual(result.kind, 'cancelled'); + assert.strictEqual(list.callCount, 2, 'Do not start a new inventory command after cancellation'); + assert.strictEqual(run.callCount, 1, 'Only the completed installation should have run'); + assert.strictEqual(await fs.readFile(value.runtime.executable, 'utf8'), 'Inert unit-test data. This file must never be executed.'); + sinon.assert.notCalled(errors); + assertNoUv(); + }); + + for (const inventory of ['pre-install', 'post-install'] as const) { + test(`cancels the ${inventory} inventory without reporting provider failure or trying UV`, async () => { + const inventoryCall = inventory === 'pre-install' ? 1 : 2; + let receivedToken: CancellationToken | undefined; + list.onCall(inventoryCall).callsFake(async (_executable: string, options: PymanagerListOptions) => { + receivedToken = options.cancellationToken; + progressToken.cancel(); + if (receivedToken?.isCancellationRequested) { + throw new CancellationError(); + } + throw new InstallerProcessError('timeout'); + }); + approve(); + + const result = await installer.promptInstallPythonDetailed('inlineScript', log); + + assert.strictEqual(receivedToken, progressToken.token, 'Every inventory command within the approved operation needs its cancellation token'); + assert.strictEqual(result.kind, 'cancelled'); + assert.strictEqual(nativeCalls().length, inventory === 'pre-install' ? 0 : 1); + assert.strictEqual(list.callCount, inventoryCall + 1); + sinon.assert.notCalled(errors); + assertNoUv(); + }); + } + + test('preserves cancellation while fetching the catalogue before consent', async () => { + resolveCandidate.rejects(new CancellationError()); + assert.strictEqual((await installer.promptInstallPythonDetailed('inlineScript', log)).kind, 'cancelled'); + sinon.assert.notCalled(information); + sinon.assert.notCalled(errors); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('reports timeout uncertainty and never attempts UV after native mutation began', async () => { + approve(); + nativeInstall = async () => { throw new InstallerProcessError('timeout', undefined, true); }; + const result = await installer.promptInstallPythonDetailed('activation', log); + assertFailure(result, 'install-failed'); + sinon.assert.calledOnceWithExactly(errors, PythonInstallStrings.timedOut); + assert.match(PythonInstallStrings.timedOut, /background work may still be running/i); + assert.strictEqual(list.callCount, 2); + assertNoUv(); + }); + }); + + suite('provider-first global version picker', () => { + test('opens the UV picker only after provider selection and installs only the picked version', async () => { + detect.resolves({ kind: 'absent' }); + const value = await fixture(); + uvPicker.resolves('3.14.6'); + uvInstall.resolves(value.runtime.executable); + assert.strictEqual(await installer.selectAndInstallPython(log), value.runtime.executable); + sinon.assert.callOrder(detect, uvPicker, uvInstall); + sinon.assert.calledOnceWithExactly(uvPicker, log); + sinon.assert.calledOnceWithExactly(uvInstall, log, '3.14.6'); + sinon.assert.notCalled(uvInlinePreflight); + sinon.assert.notCalled(list); + sinon.assert.notCalled(run); + }); + + test('does not install after the UV version picker is dismissed', async () => { + detect.resolves({ kind: 'absent' }); + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnce(uvPicker); + sinon.assert.notCalled(uvInstall); + }); + + test('does not preflight UV when the selected Windows manager is unusable', async () => { + detect.resolves({ kind: 'unusable', executable: managerExecutable, error: new Error('busy') }); + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnceWithExactly(errors, PythonInstallStrings.managerUnusable); + assertNoUv(); + }); + + test('uses the PIM catalogue and installed labels, then rechecks and reuses the picked runtime', async () => { + const value = await fixture(); + list.onCall(0).resolves([value.runtime]); + list.onCall(1).resolves([onlineRuntime()]); + list.onCall(2).resolves([value.runtime]); + quickPick.callsFake(async (items: readonly { candidate: PymanagerCandidate }[]) => items[0]); + assert.strictEqual(await installer.selectAndInstallPython(log), await fs.realpath(value.runtime.executable)); + assert.deepStrictEqual(list.firstCall.args[1], { onlyManaged: true }); + assert.deepStrictEqual(list.secondCall.args[1], { online: true }); + const item = quickPick.firstCall.args[0][0]; + assert.strictEqual(item.label, 'Python 3.14.6'); + assert.strictEqual(item.description, PythonInstallStrings.installed); + assert.strictEqual(item.detail, value.runtime.executable); + sinon.assert.notCalled(information); + sinon.assert.notCalled(resolveCandidate); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('ignores legacy automatic-prompt opt-out for an explicitly opened global picker', async () => { + optOut.resolves(true); + list.onCall(0).resolves([]); + list.onCall(1).resolves([onlineRuntime()]); + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnce(quickPick); + sinon.assert.notCalled(optOut); + assert.strictEqual(nativeCalls().length, 0); + }); + + test('obtains install/update consent after a PIM picker selection rather than treating selection as approval', async () => { + list.onCall(0).resolves([]); + list.onCall(1).resolves([onlineRuntime()]); + list.onCall(2).resolves([]); + quickPick.callsFake(async (items: readonly { candidate: PymanagerCandidate }[]) => items[0]); + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnce(information); + assert.ok(quickPick.firstCall.calledBefore(information.firstCall)); + assert.deepStrictEqual(information.firstCall.args.slice(1), [ + { modal: true }, PythonInstallStrings.installAction('3.14.6'), + ]); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + + test('filters nonstandard builds out of the PIM picker before any consent', async () => { + list.onCall(0).resolves([]); + list.onCall(1).resolves([ + onlineRuntime('3.14.6'), + onlineRuntime('3.14.7', { company: 'Other' }), + onlineRuntime('3.14.7', { tag: '3.14t-64' }), + onlineRuntime('3.14.7', { executableArgs: ['-X', 'special'] }), + ]); + await installer.selectAndInstallPython(log); + assert.strictEqual(quickPick.firstCall.args[0].length, 1); + assert.strictEqual(quickPick.firstCall.args[0][0].candidate.installTag, 'PythonCore\\3.14.6-64'); + assert.strictEqual(nativeCalls().length, 0); + }); + + test('reports an empty PIM catalogue without opening a picker or trying UV', async () => { + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnceWithExactly(errors, PythonInstallStrings.noCompatiblePython); + sinon.assert.notCalled(quickPick); + assertNoUv(); + }); + + test('classifies a failed PIM picker catalogue as a catalogue error, not an installation failure', async () => { + list.onCall(0).resolves([]); + list.onCall(1).rejects(new InstallerProcessError('exit', 7)); + assert.strictEqual(await installer.selectAndInstallPython(log), undefined); + sinon.assert.calledOnceWithExactly(errors, PythonInstallStrings.catalogueFailed); + sinon.assert.notCalled(quickPick); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + }); + + suite('queue and race behavior', () => { + test('serializes concurrent pickers as local/online/local/online with the first local query deferred', async () => { + const firstLocalStarted = gate(); + const releaseFirstLocal = gate(); + const order: string[] = []; + let localQueries = 0; + let inFlight = 0; + let maximumInFlight = 0; + list.callsFake(async (command: string, options?: PymanagerListOptions) => { + assert.strictEqual(command, managerExecutable); + const phase = options?.online ? 'online' : 'local'; + assert.deepStrictEqual(options, phase === 'online' ? { online: true } : { onlyManaged: true }); + order.push(phase); + maximumInFlight = Math.max(maximumInFlight, ++inFlight); + try { + if (phase === 'local' && ++localQueries === 1) { + firstLocalStarted.resolve(); + await releaseFirstLocal.promise; + } + return phase === 'online' ? [onlineRuntime()] : []; + } finally { + inFlight -= 1; + } + }); + const first = track(installer.selectAndInstallPython(log)); + await entered(firstLocalStarted, first); + const second = track(installer.selectAndInstallPython(log)); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepStrictEqual(order, ['local']); + sinon.assert.calledOnce(list); + sinon.assert.notCalled(quickPick); + releaseFirstLocal.resolve(); + assert.deepStrictEqual(await Promise.all([first, second]), [undefined, undefined]); + assert.deepStrictEqual(order, ['local', 'online', 'local', 'online']); + assert.strictEqual(maximumInFlight, 1, 'Native commands from concurrent pickers must never overlap'); + assert.strictEqual(inFlight, 0); + assert.strictEqual(list.callCount, 4); + sinon.assert.calledTwice(quickPick); + sinon.assert.calledOnce(detect); + sinon.assert.notCalled(run); + assertNoUv(); + }); + + test('queues another request behind native installation, avoids busy-manager reprobes, and reuses the result', async () => { + const value = await fixture(); + let installed: PymanagerRuntime[] = []; + list.callsFake(async () => installed); + approve(); + const started = gate(); + const release = gate(); + nativeInstall = async () => { + started.resolve(); + await release.promise; + installed = [value.runtime]; + }; + const first = track(installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' })); + await entered(started, first); + detect.onSecondCall().resolves({ kind: 'unusable', executable: managerExecutable, error: new Error('busy') }); + const second = track(installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' })); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(list.callCount, 2, 'A local list must not race our in-flight install'); + sinon.assert.calledOnce(detect); + assert.strictEqual(nativeCalls().length, 1); + release.resolve(); + const results = await Promise.all([first, second]); + assert.ok(results.every((result) => result.kind === 'installed')); + assert.strictEqual(nativeCalls().length, 1); + assert.strictEqual(list.callCount, 4); + assert.strictEqual(resolveCandidate.callCount, 1); + assertNoUv(); + }); + + test('clears active-provider state after completion so a later independent request probes again', async () => { + assert.strictEqual((await installer.promptInstallPythonDetailed('activation', log)).kind, 'declined'); + detect.onSecondCall().resolves({ kind: 'absent' }); + assert.deepStrictEqual(await installer.getPythonInstaller(), { kind: 'uv' }); + assert.strictEqual(detect.callCount, 2); + }); + + test('continues the queue after a failed native operation without switching providers', async () => { + const value = await fixture(); + let installed: PymanagerRuntime[] = []; + let attempts = 0; + list.callsFake(async () => installed); + approve(); + const started = gate(); + const release = gate(); + nativeInstall = async () => { + if (++attempts === 1) { + started.resolve(); + await release.promise; + throw new InstallerProcessError('exit', 7); + } + installed = [value.runtime]; + }; + const first = track(installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' })); + await entered(started, first); + const second = track(installer.promptInstallPythonDetailed('activation', log, { version: '3.14.6' })); + release.resolve(); + const [failed, succeeded] = await Promise.all([first, second]); + assertFailure(failed, 'install-failed'); + assert.strictEqual(succeeded.kind, 'installed'); + assert.strictEqual(nativeCalls().length, 2); + assertNoUv(); + }); + + test('serializes picker inventory/catalogue work with another request instead of timing out a local query', async () => { + const onlineStarted = gate(); + const releaseOnline = gate(); + list.callsFake(async (_command: string, options?: PymanagerListOptions) => { + if (options?.online) { + onlineStarted.resolve(); + await releaseOnline.promise; + return [onlineRuntime()]; + } + return []; + }); + const first = track(installer.selectAndInstallPython(log)); + await entered(onlineStarted, first); + const second = track(installer.promptInstallPythonDetailed('activation', log)); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(list.callCount, 2); + assert.deepStrictEqual(list.firstCall.args[1], { onlyManaged: true }); + assert.deepStrictEqual(list.secondCall.args[1], { online: true }); + sinon.assert.calledOnce(detect); + releaseOnline.resolve(); + const [selected, result] = await Promise.all([first, second]); + assert.strictEqual(selected, undefined); + assert.strictEqual(result.kind, 'declined'); + assert.strictEqual(list.callCount, 3); + assert.strictEqual(nativeCalls().length, 0); + assertNoUv(); + }); + }); +}); diff --git a/src/test/managers/builtin/pythonRuntimeCandidates.unit.test.ts b/src/test/managers/builtin/pythonRuntimeCandidates.unit.test.ts new file mode 100644 index 000000000..7d7978def --- /dev/null +++ b/src/test/managers/builtin/pythonRuntimeCandidates.unit.test.ts @@ -0,0 +1,779 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { PythonVersion } from '../../../common/pythonVersion'; +import { InstallerProcessError } from '../../../common/installerProcess'; +import * as pymanager from '../../../managers/builtin/pymanagerPythonInstaller'; +import { PymanagerRuntime } from '../../../managers/builtin/pymanagerPythonInstaller'; +import { PythonInstallationError, PythonInstallRequest } from '../../../managers/builtin/pythonInstallerTypes'; +import { + getPreferredPythonSelector, + getPymanagerCandidates, + getPymanagerRuntimeVersion, + getPythonRuntimeArchitecture, + matchesPythonInstallRequest, + PythonRuntimeArchitecture, + resolvePymanagerCandidate, + toPymanagerCandidate, + validatePythonInstallRequest, +} from '../../../managers/builtin/pythonRuntimeCandidates'; + +const SUFFIXES: Readonly> = { x64: '64', arm64: 'arm64', ia32: '32' }; + +function tag(version: string, architecture: PythonRuntimeArchitecture = 'x64'): string { + return `PythonCore\\${version}-${SUFFIXES[architecture]}`; +} + +function runtime( + version: string, + overrides: Partial = {}, + architecture: PythonRuntimeArchitecture = 'x64', +): PymanagerRuntime { + const family = version.split('.').slice(0, 2).join('.'); + const development = /(?:a|b|rc)\d+$/i.test(version) ? '-dev' : ''; + const suffix = SUFFIXES[architecture]; + return { + id: `pythoncore-${family}${development}-${suffix}`, + company: 'PythonCore', + version, + tag: `${family}${development}-${suffix}`, + installTags: [`${family}${development}-${suffix}`, `${version}-${suffix}`], + executable: 'python.exe', + executableArgs: [], + unmanaged: false, + ...overrides, + }; +} + +interface CatalogueStep { + readonly selectors: readonly string[]; + readonly runtimes: readonly PymanagerRuntime[]; +} + +suite('pythonRuntimeCandidates', () => { + const executable = path.join(process.cwd(), 'resolver fixtures', 'pymanager.exe'); + let hostArchitecture: sinon.SinonStub; + let list: sinon.SinonStub< + Parameters, + ReturnType + >; + + setup(() => { + hostArchitecture = sinon.stub(process, 'arch').value('x64'); + list = sinon.stub(pymanager, 'listPymanagerRuntimes').rejects(new Error('Unexpected catalogue query')); + }); + + teardown(() => sinon.restore()); + + function catalogue(steps: readonly CatalogueStep[]): () => void { + let next = 0; + list.callsFake(async (command, options) => { + const step = steps[next++]; + assert.ok(step, `Unexpected catalogue query ${next}: ${JSON.stringify(options?.selectors)}`); + assert.strictEqual(command, executable); + assert.strictEqual(options?.online, true); + assert.deepStrictEqual(options?.selectors, step.selectors, `Catalogue query ${next}`); + assert.strictEqual(options?.onlyManaged, undefined); + return [...step.runtimes]; + }); + return () => assert.strictEqual(list.callCount, steps.length, 'Expected all catalogue steps to be used'); + } + + function isBudgetError(error: unknown): boolean { + assert.ok(error instanceof PythonInstallationError); + assert.strictEqual(error.reason, 'catalogue-failed'); + return true; + } + + suite('request semantics', () => { + const cases: readonly { + readonly actual: string; + readonly request: PythonInstallRequest; + readonly matches: boolean; + }[] = [ + { actual: '3.14.7', request: {}, matches: true }, + { actual: '3.15.0a7', request: {}, matches: false }, + { actual: '2.7.18', request: {}, matches: false }, + { actual: '4.0.0', request: {}, matches: false }, + { actual: '3.14.7', request: { version: '3' }, matches: true }, + { actual: '3.13.12', request: { version: '3.13' }, matches: true }, + { actual: '3.14.0', request: { version: '3.13' }, matches: false }, + { actual: '3.13.0', request: { version: '3.13.0' }, matches: true }, + { actual: '3.13.1', request: { version: '3.13.0' }, matches: false }, + { actual: '3.13.0', request: { requiresPython: '==3.13' }, matches: true }, + { actual: '3.13.1', request: { requiresPython: '==3.13' }, matches: false }, + { actual: '3.13.12', request: { requiresPython: '==3.13.*' }, matches: true }, + { actual: '3.14.0', request: { requiresPython: '==3.13.*' }, matches: false }, + { actual: '3.13.12', request: { requiresPython: '>=3.13,<3.14' }, matches: true }, + { actual: '3.14.0', request: { requiresPython: '>=3.13,<3.14' }, matches: false }, + { actual: '3.14.6', request: { requiresPython: '!=3.14.7' }, matches: true }, + { actual: '3.14.7', request: { requiresPython: '!=3.14.7' }, matches: false }, + { actual: '3.13.12', request: { requiresPython: '~=3.13.2' }, matches: true }, + { actual: '3.14.0', request: { requiresPython: '~=3.13.2' }, matches: false }, + { actual: '3.15.0rc3', request: { version: '3.15.0candidate3' }, matches: true }, + { actual: '3.15.0rc4', request: { version: '3.15.0rc3' }, matches: false }, + { actual: '3.15.0rc3', request: { version: '3.15' }, matches: false }, + { actual: '3.15.0rc3', request: { requiresPython: '>=3.15' }, matches: false }, + { actual: '3.15.0rc3', request: { requiresPython: '>=3.15.0rc1' }, matches: true }, + { actual: '3.14.0rc2', request: { requiresPython: '<3.14.0rc3' }, matches: true }, + { actual: '3.14.0b4', request: { requiresPython: '<3.14.0rc3' }, matches: true }, + { actual: '3.14.0rc3', request: { requiresPython: '<3.14.0rc3' }, matches: false }, + { actual: '3.14.0rc1', request: { requiresPython: '>=3.13.0rc1,<3.14' }, matches: false }, + { actual: '3.13.5rc1', request: { requiresPython: '>=3.13.0rc1,<3.14' }, matches: true }, + { actual: '3.13.12', request: { version: '3.13', requiresPython: '<3.13.10' }, matches: false }, + { actual: '3.13.9', request: { version: '3.13', requiresPython: '<3.13.10' }, matches: true }, + { actual: '3.14.7', request: { version: '3.13', requiresPython: '>=3.14' }, matches: false }, + { actual: '3.13.0', request: { version: ' 3.13 ', requiresPython: ' == 3.13 ' }, matches: true }, + { actual: '3.14.7', request: { version: 'bad' }, matches: false }, + { actual: '3.14.7', request: { requiresPython: 'bad' }, matches: false }, + ]; + + for (const entry of cases) { + test(`${entry.actual} matches ${JSON.stringify(entry.request)}: ${entry.matches}`, () => { + assert.strictEqual( + matchesPythonInstallRequest(new PythonVersion(entry.actual), entry.request), + entry.matches, + ); + sinon.assert.notCalled(list); + }); + } + + const invalid: readonly PythonInstallRequest[] = [ + { version: '' }, + { version: ' ' }, + { version: '3.x' }, + { version: '>=3.13' }, + { version: '3.13.*' }, + { version: '3.14.0.post1' }, + { version: '3.14.0+local' }, + { version: '3.14\n--force' }, + { requiresPython: '' }, + { requiresPython: '3.13' }, + { requiresPython: '>=3.13.*' }, + { requiresPython: '~=3' }, + { requiresPython: '>=3.13,' }, + { requiresPython: '>=3.13,invalid' }, + { requiresPython: '>=3.13 || <3.12' }, + ]; + + for (const request of invalid) { + test(`rejects invalid request before catalogue access: ${JSON.stringify(request)}`, async () => { + const check = (error: unknown): boolean => { + assert.ok(error instanceof PythonInstallationError); + assert.strictEqual(error.reason, 'no-compatible-python'); + return true; + }; + assert.throws(() => validatePythonInstallRequest(request), check); + await assert.rejects(resolvePymanagerCandidate(executable, request), check); + sinon.assert.notCalled(list); + }); + } + + test('accepts an unspecified request and valid but contradictory clauses for later resolution', () => { + assert.doesNotThrow(() => validatePythonInstallRequest({})); + assert.doesNotThrow(() => validatePythonInstallRequest({ requiresPython: '>=3.14,<3.13' })); + }); + }); + + suite('preferred selectors', () => { + const cases: readonly [string | undefined, string | undefined][] = [ + [undefined, undefined], + ['', undefined], + [' ', undefined], + ['>=3', '3'], + ['>=3.13', '3.13'], + ['>= 3.13 ', '3.13'], + ['>=v3.13', '3.13'], + ['>=3.13.2', '3.13.2'], + ['==3.13', '3.13.0'], + ['==3.13.2', '3.13.2'], + ['==3.13.*', undefined], + ['~=3.13.2', undefined], + ['>3.13', undefined], + ['<3.14', undefined], + ['<=3.14', undefined], + ['!=3.14.7', undefined], + ['>=3.13,<3.14', undefined], + ['>=3.13,!=3.13.0', undefined], + ['>=3.15.0rc1,<3.16', '3.15.0rc1'], + ['==3.15.0beta2', '3.15.0b2'], + ['>=3.15.0b2,!=3.15.0b2', undefined], + ['>=2.7', undefined], + ['>=3.13.*', undefined], + ['invalid', undefined], + [' >=3.13 ', '3.13'], + [' == 3.13 ', '3.13.0'], + ]; + + for (const [constraint, expected] of cases) { + test(`${JSON.stringify(constraint)} prefers ${JSON.stringify(expected)}`, () => { + assert.strictEqual(getPreferredPythonSelector(constraint), expected); + sinon.assert.notCalled(list); + }); + } + }); + + suite('architecture and runtime metadata', () => { + for (const architecture of ['x64', 'arm64', 'ia32'] as const) { + test(`uses extension-host ${architecture} and its native installation suffix`, () => { + hostArchitecture.value(architecture); + assert.strictEqual(getPythonRuntimeArchitecture(), architecture); + const entry = runtime('3.14.7', {}, architecture); + const candidate = toPymanagerCandidate(entry); + assert.ok(candidate); + assert.strictEqual(candidate.architecture, architecture); + assert.strictEqual(candidate.installTag, tag('3.14.7', architecture)); + assert.strictEqual(candidate.runtime, entry); + assert.strictEqual(candidate.version.toString(), '3.14.7'); + }); + } + + test('classifies unsupported host architecture instead of guessing from the desktop or executable', () => { + hostArchitecture.value('s390x'); + assert.throws(getPythonRuntimeArchitecture, (error: unknown) => { + assert.ok(error instanceof PythonInstallationError); + assert.strictEqual(error.reason, 'provider-unusable'); + return true; + }); + }); + + test('derives the actual release from version rather than the slot ID or short tag', () => { + const entry = runtime('3.14.7', { id: 'pythoncore-3.14-64', tag: '3.14-64' }); + assert.strictEqual(getPymanagerRuntimeVersion(entry)?.toString(), '3.14.7'); + assert.strictEqual(toPymanagerCandidate(entry)?.installTag, tag('3.14.7')); + }); + + test('accepts standard development slots and preserves advertised tag casing', () => { + const entry = runtime( + '3.15.0rc3', + { company: 'PYTHONCORE', tag: '3.15-dev-ARM64', installTags: ['3.15.0RC3-ARM64'] }, + 'arm64', + ); + assert.strictEqual(toPymanagerCandidate(entry, 'arm64')?.installTag, 'PythonCore\\3.15.0RC3-ARM64'); + }); + + const rejected: readonly [string, Partial][] = [ + ['another company', { company: 'Other' }], + ['lookalike company', { company: 'PythonCore.Other' }], + ['Python 2', { version: '2.7.18' }], + ['Python 4', { version: '4.0.0' }], + ['invalid actual version', { version: 'not a release' }], + ['package dev version', { version: '3.14.7.dev1' }], + ['free-threaded family', { tag: '3.14t-64' }], + ['free-threaded concrete tag', { tag: '3.14.7t-64' }], + ['embeddable build', { tag: '3.14-embed-64' }], + ['debug build', { tag: '3.14-debug-64' }], + ['wrong architecture arm64', { tag: '3.14-arm64' }], + ['wrong architecture ia32', { tag: '3.14-32' }], + ['lookalike architecture suffix', { tag: '3.14-64-extra' }], + ['missing architecture', { tag: '3.14' }], + ['unqualified family', { tag: '3-64' }], + ['required executable args', { executableArgs: ['-X', 'gil=0'] }], + ]; + + for (const [name, overrides] of rejected) { + test(`rejects ${name}`, () => { + const entry = runtime('3.14.7', overrides); + assert.strictEqual(getPymanagerRuntimeVersion(entry, 'x64'), undefined); + assert.strictEqual(toPymanagerCandidate(entry, 'x64'), undefined); + }); + } + + for (const installTags of [ + [], + ['3.14', '3.14-64'], + ['3.14.6-64'], + ['3.14.7-arm64'], + ['3.14.7-64-extra'], + ['PythonCore\\3.14.7-64'], + ]) { + test(`does not invent a concrete tag from ${JSON.stringify(installTags)}`, () => { + const entry = runtime('3.14.7', { tag: '3.14.7-64', installTags }); + assert.ok(getPymanagerRuntimeVersion(entry)); + assert.strictEqual(toPymanagerCandidate(entry), undefined); + }); + } + + test('sorts eligible actual releases without mutating the catalogue or its records', () => { + const entries = Object.freeze([ + Object.freeze(runtime('3.13.12')), + Object.freeze(runtime('3.15.0a7')), + Object.freeze(runtime('3.14.7')), + Object.freeze(runtime('3.16.0', { company: 'Other' })), + ]); + assert.deepStrictEqual( + getPymanagerCandidates(entries).map((candidate) => candidate.version.toString()), + ['3.15.0a7', '3.14.7', '3.13.12'], + ); + assert.deepStrictEqual(entries.map((entry) => entry.version), ['3.13.12', '3.15.0a7', '3.14.7', '3.16.0']); + }); + }); + + suite('catalogue resolution', () => { + test('chooses the newest stable standard build by default, not the catalogue input order', async () => { + const verify = catalogue([ + { + selectors: [], + runtimes: [ + runtime('3.13.12'), + runtime('3.15.0a7'), + runtime('3.14.7'), + runtime('3.16.0', { company: 'Other' }), + ], + }, + ]); + const result = await resolvePymanagerCandidate(executable, {}); + assert.strictEqual(result?.installTag, tag('3.14.7')); + verify(); + }); + + test('returns undefined for an empty catalogue or one containing only future prereleases', async () => { + list.resolves([]); + assert.strictEqual(await resolvePymanagerCandidate(executable, {}), undefined); + list.resolves([runtime('3.15.0a7')]); + assert.strictEqual(await resolvePymanagerCandidate(executable, {}), undefined); + assert.strictEqual(list.callCount, 2); + }); + + const selectors: readonly [PythonInstallRequest, string, string][] = [ + [{ version: '3.13' }, '3.13', '3.13.12'], + [{ version: '3' }, '3', '3.14.7'], + [{ version: '3.13.2' }, '3.13.2', '3.13.2'], + [{ requiresPython: '==3.13' }, '3.13.0', '3.13.0'], + [{ requiresPython: '==3.13.2' }, '3.13.2', '3.13.2'], + [{ requiresPython: '>=3.13' }, '3.13', '3.13.12'], + [{ version: '3.15.0candidate3' }, '3.15.0rc3', '3.15.0rc3'], + [{ requiresPython: '>=3.15.0rc1,<3.16' }, '3.15.0rc1', '3.15.0rc1'], + ]; + + for (const [request, querySelector, actual] of selectors) { + test(`queries ${querySelector} for ${JSON.stringify(request)}`, async () => { + const verify = catalogue([{ selectors: [tag(querySelector)], runtimes: [runtime(actual)] }]); + const result = await resolvePymanagerCandidate(executable, request); + assert.strictEqual(result?.installTag, tag(actual)); + verify(); + }); + } + + test('keeps simple lower-bound family preference despite harmless surrounding whitespace', async () => { + list.callsFake(async (_command, options) => + options?.selectors?.length + ? [runtime('3.13.12')] + : [runtime('3.14.7'), runtime('3.13.12')], + ); + const result = await resolvePymanagerCandidate(executable, { requiresPython: ' >=3.13 ' }); + assert.strictEqual(result?.version.toString(), '3.13.12'); + assert.deepStrictEqual(list.firstCall.args[1]?.selectors, [tag('3.13')]); + }); + + test('does not pin compound constraints to their lower bound', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.13.12'), runtime('3.14.7'), runtime('3.15.0')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '>=3.13,<3.15' }); + assert.strictEqual(result?.version.toString(), '3.14.7'); + verify(); + }); + + test('honors an explicit family selector together with its constraint', async () => { + const verify = catalogue([{ selectors: [tag('3.13')], runtimes: [runtime('3.13.12')] }]); + const result = await resolvePymanagerCandidate(executable, { + version: '3.13', + requiresPython: '>=3.12,<3.14', + }); + assert.strictEqual(result?.version.toString(), '3.13.12'); + verify(); + }); + + test('treats a derived family preference as a preference, not an additional constraint', async () => { + const verify = catalogue([ + { selectors: [tag('3.13')], runtimes: [] }, + { selectors: [], runtimes: [runtime('3.14.7')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '>=3.13' }); + assert.strictEqual(result?.version.toString(), '3.14.7'); + verify(); + }); + + test('does not fall back to a different family when the selector was explicit', async () => { + const verify = catalogue([{ selectors: [tag('3.13')], runtimes: [] }]); + assert.strictEqual( + await resolvePymanagerCandidate(executable, { version: '3.13', requiresPython: '>=3.13' }), + undefined, + ); + verify(); + }); + + test('falls back from an unpublished >=3.14.6 preference to compatible 3.14.7', async () => { + const verify = catalogue([ + { selectors: [tag('3.14.6')], runtimes: [] }, + { selectors: [], runtimes: [runtime('3.14.7')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '>=3.14.6' }); + assert.strictEqual(result?.version.toString(), '3.14.7'); + verify(); + }); + + test('does not broaden exact short equality when its preferred patch is missing', async () => { + const verify = catalogue([ + { selectors: [tag('3.13.0')], runtimes: [] }, + { selectors: [], runtimes: [runtime('3.13.12')] }, + { selectors: [tag('3.13.0')], runtimes: [] }, + ]); + assert.strictEqual( + await resolvePymanagerCandidate(executable, { requiresPython: '==3.13' }), + undefined, + ); + verify(); + }); + + test('does not install a different patch when an exact selector returns a false positive', async () => { + const verify = catalogue([{ selectors: [tag('3.14.6')], runtimes: [runtime('3.14.7')] }]); + assert.strictEqual(await resolvePymanagerCandidate(executable, { version: '3.14.6' }), undefined); + verify(); + }); + + test('does not substitute a final or a different serial for an exact prerelease', async () => { + const verify = catalogue([{ selectors: [tag('3.14.0rc2')], runtimes: [runtime('3.14.0rc3')] }]); + assert.strictEqual(await resolvePymanagerCandidate(executable, { version: '3.14.0rc2' }), undefined); + verify(); + }); + + test('rejects syntactically valid but contradictory requirements without inventing queries', async () => { + const verify = catalogue([{ selectors: [], runtimes: [runtime('3.14.7'), runtime('3.13.12')] }]); + assert.strictEqual( + await resolvePymanagerCandidate(executable, { requiresPython: '>=3.14,<3.13' }), + undefined, + ); + verify(); + }); + + const histories: readonly [string, string, readonly number[]][] = [ + ['>=3.14,<3.14.7', '3.14.6', [6, 5, 4, 3, 2, 1, 0]], + ['<=3.14.6', '3.14.6', [6, 5, 4, 3, 2, 1, 0]], + ['!=3.14.7', '3.14.6', [6, 5, 4, 3, 2, 1, 0]], + ['>=3.14,<3.15,!=3.14.7,!=3.14.6', '3.14.5', [5, 4, 3, 2, 1, 0]], + ['>=3.14.2,<3.14.6,!=3.14.4', '3.14.5', [5, 3, 2]], + ]; + + for (const [requiresPython, expected, patches] of histories) { + test(`finds a hidden final patch for ${requiresPython} through exact compatible tags`, async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: patches.map((patch) => tag(`3.14.${patch}`)), runtimes: [runtime(expected)] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython }); + assert.strictEqual(result?.installTag, tag(expected)); + verify(); + }); + } + + test('rejects native tag/range false positives using the actual version', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7', { tag: '3.14-64' })] }, + { + selectors: [6, 5, 4, 3, 2, 1, 0].map((patch) => tag(`3.14.${patch}`)), + runtimes: [runtime('3.14.7', { tag: '3.14-64' })], + }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.7' }); + assert.strictEqual(result, undefined); + verify(); + }); + + test('does not synthesize an installable runtime when exact history queries have no matches', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.6'), tag('3.14.5')], runtimes: [] }, + ]); + assert.strictEqual( + await resolvePymanagerCandidate(executable, { requiresPython: '>=3.14.5,<3.14.7' }), + undefined, + ); + verify(); + }); + + test('batches descending exact final tags into at most 64 selectors and searches later batches', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.130')] }, + { selectors: Array.from({ length: 64 }, (_value, i) => tag(`3.14.${129 - i}`)), runtimes: [] }, + { selectors: Array.from({ length: 64 }, (_value, i) => tag(`3.14.${65 - i}`)), runtimes: [] }, + { selectors: [tag('3.14.1'), tag('3.14.0')], runtimes: [runtime('3.14.1')] }, + ]); + assert.strictEqual( + (await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.130' }))?.version.toString(), + '3.14.1', + ); + verify(); + }); + + test('tries lower families only after exhausting eligible history of the newest family', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.13.12'), runtime('3.14.7')] }, + { selectors: [tag('3.14.1'), tag('3.14.0')], runtimes: [] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.2' }); + assert.strictEqual(result?.version.toString(), '3.13.12'); + verify(); + }); + + test('requires an advertised concrete tag and no required executable arguments even for exact queries', async () => { + const verify = catalogue([ + { + selectors: [tag('3.14.6')], + runtimes: [ + runtime('3.14.6', { id: 'missing-concrete-tag', installTags: ['3.14-64'] }), + runtime('3.14.6', { id: 'requires-arguments', executableArgs: ['-X', 'special'] }), + ], + }, + ]); + assert.strictEqual(await resolvePymanagerCandidate(executable, { version: '3.14.6' }), undefined); + verify(); + }); + + for (const architecture of ['x64', 'arm64', 'ia32'] as const) { + test(`threads explicit ${architecture} through native queries and returned candidate validation`, async () => { + const wrongArchitecture = architecture === 'x64' ? 'arm64' : 'x64'; + const verify = catalogue([ + { + selectors: [tag('3.14.6', architecture)], + runtimes: [runtime('3.14.6', {}, wrongArchitecture), runtime('3.14.6', {}, architecture)], + }, + ]); + const result = await resolvePymanagerCandidate(executable, { version: '3.14.6' }, architecture); + assert.strictEqual(result?.architecture, architecture); + assert.strictEqual(result?.installTag, tag('3.14.6', architecture)); + verify(); + }); + } + + test('propagates native catalogue failures rather than returning no compatible version', async () => { + const error = new InstallerProcessError('exit', 7); + list.rejects(error); + await assert.rejects(resolvePymanagerCandidate(executable, {}), (caught: unknown) => caught === error); + sinon.assert.calledOnce(list); + }); + }); + + suite('prerelease catalogue history', () => { + for (const [phase, serial] of [['rc', 3], ['b', 4], ['a', 7]] as const) { + test(`queries the native ${phase} prefix and returns the advertised serial`, async () => { + const phases = ['rc', 'b', 'a']; + const target = `3.14.0${phase}${serial}`; + const steps: CatalogueStep[] = [{ selectors: [], runtimes: [runtime('3.14.7')] }]; + for (const current of phases.slice(0, phases.indexOf(phase) + 1)) { + steps.push({ + selectors: [tag(`3.14.0${current}`)], + runtimes: current === phase ? [runtime(target)] : [], + }); + } + const verify = catalogue(steps); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.0rc4' }); + assert.strictEqual(result?.version.toString(), target); + verify(); + }); + } + + test('backtracks rc serials and excludes native prefix results outside the request', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0rc')], runtimes: [runtime('3.14.0rc3')] }, + { selectors: [tag('3.14.0rc2'), tag('3.14.0rc0')], runtimes: [runtime('3.14.0rc2')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { + requiresPython: '<3.14.0rc3,!=3.14.0rc1', + }); + assert.strictEqual(result?.version.toString(), '3.14.0rc2'); + verify(); + }); + + test('searches compatible prereleases when a derived lower-bound serial is unpublished', async () => { + const verify = catalogue([ + { selectors: [tag('3.14.0rc1')], runtimes: [] }, + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0rc')], runtimes: [runtime('3.14.0rc3')] }, + { selectors: [tag('3.14.0rc2'), tag('3.14.0rc1')], runtimes: [runtime('3.14.0rc2')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { + requiresPython: '>=3.14.0rc1,<3.14.0rc3', + }); + assert.strictEqual(result?.version.toString(), '3.14.0rc2'); + verify(); + }); + + test('skips the rc phase and backtracks beta serials for a beta-only upper bound', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0b')], runtimes: [runtime('3.14.0b4')] }, + { + selectors: [tag('3.14.0b3'), tag('3.14.0b2'), tag('3.14.0b1'), tag('3.14.0b0')], + runtimes: [runtime('3.14.0b3')], + }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '>3.14.0a0,<3.14.0b4' }); + assert.strictEqual(result?.version.toString(), '3.14.0b3'); + verify(); + }); + + test('backtracks alpha serials without probing higher incompatible phases', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0a')], runtimes: [runtime('3.14.0a7')] }, + { selectors: [tag('3.14.0a6'), tag('3.14.0a5'), tag('3.14.0a4')], runtimes: [runtime('3.14.0a5')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '>3.14.0a3,<3.14.0a7' }); + assert.strictEqual(result?.version.toString(), '3.14.0a5'); + verify(); + }); + + test('queries beta after an rc prefix has no compatible historical matches', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0rc')], runtimes: [runtime('3.14.0rc3')] }, + { selectors: [tag('3.14.0rc2'), tag('3.14.0rc1'), tag('3.14.0rc0')], runtimes: [] }, + { selectors: [tag('3.14.0b')], runtimes: [runtime('3.14.0b4')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.0rc3' }); + assert.strictEqual(result?.version.toString(), '3.14.0b4'); + verify(); + }); + + test('ignores a prefix response from the wrong numeric release or phase', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { + selectors: [tag('3.14.0rc')], + runtimes: [ + runtime('3.13.0rc3'), + runtime('3.14.0a7', { id: 'wrong-phase' }), + runtime('3.14.1rc2', { id: 'wrong-patch' }), + ], + }, + { selectors: [tag('3.14.0b')], runtimes: [runtime('3.14.0b4')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.0rc4' }); + assert.strictEqual(result?.version.toString(), '3.14.0b4'); + verify(); + }); + + test('prefers a newer-patch prerelease to an older final when both satisfy the constraint', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.2')] }, + { selectors: [tag('3.14.0')], runtimes: [runtime('3.14.0')] }, + { selectors: [tag('3.14.1rc')], runtimes: [runtime('3.14.1rc3')] }, + { + selectors: [tag('3.14.1rc2'), tag('3.14.1rc1'), tag('3.14.1rc0')], + runtimes: [runtime('3.14.1rc2')], + }, + ]); + const result = await resolvePymanagerCandidate(executable, { requiresPython: '<3.14.1rc3' }); + assert.strictEqual(result?.version.toString(), '3.14.1rc2'); + verify(); + }); + + test('does not probe prereleases of an exclusive final upper bound even when prereleases are enabled', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7'), runtime('3.13.12')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { + requiresPython: '>3.13.0rc1,<3.14', + }); + assert.strictEqual(result?.version.toString(), '3.13.12'); + verify(); + }); + + test('batches compatible prerelease serials, excluding out-of-range or explicitly excluded serials', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0rc')], runtimes: [runtime('3.14.0rc130')] }, + { + selectors: Array.from({ length: 64 }, (_value, i) => tag(`3.14.0rc${129 - i}`)), + runtimes: [], + }, + { + selectors: Array.from({ length: 64 }, (_value, i) => tag(`3.14.0rc${65 - i}`)), + runtimes: [], + }, + { selectors: [tag('3.14.0rc1')], runtimes: [runtime('3.14.0rc1')] }, + ]); + const result = await resolvePymanagerCandidate(executable, { + requiresPython: '<3.14.0rc130,!=3.14.0rc0', + }); + assert.strictEqual(result?.version.toString(), '3.14.0rc1'); + verify(); + }); + }); + + suite('bounded search', () => { + test('rejects release components beyond the supported historical range before generating tag batches', async () => { + list.resolves([runtime('3.14.1001')]); + await assert.rejects( + resolvePymanagerCandidate(executable, { requiresPython: '<3.14.1001' }), + isBudgetError, + ); + sinon.assert.calledOnce(list); + }); + + test('rejects unsupported prerelease serial ranges before allocating a history batch', async () => { + const verify = catalogue([ + { selectors: [], runtimes: [runtime('3.14.7')] }, + { selectors: [tag('3.14.0rc')], runtimes: [runtime('3.14.0rc1001')] }, + ]); + await assert.rejects( + resolvePymanagerCandidate(executable, { requiresPython: '<3.14.0rc3' }), + isBudgetError, + ); + verify(); + }); + + test('caps total native queries at 64 including the initial catalogue', async () => { + const versions = ['3.20.1000', '3.19.1000', '3.18.1000', '3.17.1000']; + list.resolves([]); + list.onFirstCall().resolves(versions.map((version) => runtime(version))); + await assert.rejects( + resolvePymanagerCandidate(executable, { requiresPython: versions.map((version) => `!=${version}`).join(',') }), + isBudgetError, + ); + assert.strictEqual(list.callCount, 64); + assert.deepStrictEqual(list.firstCall.args[1]?.selectors, []); + for (const call of list.getCalls().slice(1)) { + assert.ok((call.args[1]?.selectors?.length ?? 0) <= 64); + } + }); + + test('stops before another native query once the elapsed historical-search budget is exhausted', async () => { + const clock = sinon.useFakeTimers({ toFake: ['Date'] }); + list.callsFake(async () => { + clock.tick(120001); + return [runtime('3.14.7')]; + }); + await assert.rejects( + resolvePymanagerCandidate(executable, { requiresPython: '<3.14.7' }), + isBudgetError, + ); + sinon.assert.calledOnce(list); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('allows a later independent attempt after a previous query exhausted its time budget', async () => { + const clock = sinon.useFakeTimers({ toFake: ['Date'] }); + list.onFirstCall().callsFake(async () => { + clock.tick(120001); + return [runtime('3.14.7')]; + }); + await assert.rejects( + resolvePymanagerCandidate(executable, { requiresPython: '<3.14.7' }), + isBudgetError, + ); + list.onSecondCall().resolves([runtime('3.14.6')]); + assert.strictEqual( + (await resolvePymanagerCandidate(executable, { version: '3.14.6' }))?.version.toString(), + '3.14.6', + ); + assert.strictEqual(list.callCount, 2); + }); + }); +}); diff --git a/src/test/managers/builtin/sysPythonManager.pymanager.unit.test.ts b/src/test/managers/builtin/sysPythonManager.pymanager.unit.test.ts new file mode 100644 index 000000000..e0593a853 --- /dev/null +++ b/src/test/managers/builtin/sysPythonManager.pymanager.unit.test.ts @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import { promises as nativeFs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import { normalizePath } from '../../../common/utils/pathUtils'; +import { createDeferred } from '../../../common/utils/deferred'; +import * as cache from '../../../managers/builtin/cache'; +import * as installer from '../../../managers/builtin/pythonInstaller'; +import * as pim from '../../../managers/builtin/pymanagerPythonInstaller'; +import { SysPythonManager } from '../../../managers/builtin/sysPythonManager'; +import * as utils from '../../../managers/builtin/utils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('System Python PyManager integration', () => { + let sandbox: sinon.SinonSandbox; + let root: string; + let manager: SysPythonManager; + let native: PythonEnvironment; + let nativeRefresh: sinon.SinonStub; + let resolve: sinon.SinonStub; + let detect: sinon.SinonStub; + let list: sinon.SinonStub; + let prompt: sinon.SinonStub; + let select: sinon.SinonStub; + let showError: sinon.SinonStub; + let setGlobal: sinon.SinonStub; + let getProjects: sinon.SinonStub; + let workspaceSelection: sinon.SinonStub; + let globalSelection: sinon.SinonStub; + let cancellation: CancellationTokenSource; + + function environment(executable: string, version = '3.12.0'): PythonEnvironment { + return { + envId: { managerId: 'ms-python.python:system', id: executable + version }, + name: `Python ${version}`, + displayName: `Python ${version}`, + displayPath: executable, + environmentPath: Uri.file(executable), + sysPrefix: path.dirname(executable), + version, + execInfo: { run: { executable } }, + }; + } + + async function runtime(version = '3.14.6'): Promise { + const prefix = path.join(root, 'pim-runtime'); + const executable = path.join(prefix, 'python.exe'); + await fs.outputFile(executable, ''); + return { + id: 'pythoncore-3.14-64', + company: 'PythonCore', + version, + tag: '3.14-64', + installTags: [`${version}-64`], + executable, + executableArgs: [], + prefix, + unmanaged: false, + }; + } + + setup(async () => { + sandbox = sinon.createSandbox(); + cancellation = new CancellationTokenSource(); + root = await nativeFs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pim-system-unit-'))); + native = environment(path.join(root, 'legacy', 'python.exe')); + await fs.outputFile(native.execInfo.run.executable, ''); + sandbox.stub(windowApis, 'withProgress').callsFake(async (_options, action) => + action({ report: () => undefined }, cancellation.token)); + nativeRefresh = sandbox.stub(utils, 'refreshPythons').resolves([native]); + resolve = sandbox.stub(utils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); + detect = sandbox.stub(pim, 'detectPymanager').resolves({ kind: 'absent' }); + list = sandbox.stub(pim, 'listPymanagerRuntimes').resolves([]); + prompt = sandbox.stub(installer, 'promptInstallPython').resolves(undefined); + select = sandbox.stub(installer, 'selectAndInstallPython').resolves(undefined); + showError = sandbox.stub(windowApis, 'showErrorMessage').resolves(undefined); + globalSelection = sandbox.stub(cache, 'getSystemEnvForGlobal').resolves(undefined); + workspaceSelection = sandbox.stub(cache, 'getSystemEnvForWorkspace').resolves(undefined); + setGlobal = sandbox.stub(cache, 'setSystemEnvForGlobal').resolves(); + let nextId = 0; + getProjects = sandbox.stub().returns([]); + const api = { + getPythonProjects: getProjects, + getPythonProject: () => undefined, + getEnvironments: async () => { throw new Error('Do not re-enter global enumeration during initialization'); }, + createPythonEnvironmentItem: (info: PythonEnvironmentInfo) => ({ + ...info, + envId: { managerId: 'ms-python.python:system', id: `system-${nextId++}` }, + }), + } as unknown as PythonEnvironmentApi; + manager = new SysPythonManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + }); + + teardown(async () => { + cancellation.dispose(); + sandbox.restore(); + await fs.remove(root); + }); + + test('keeps native Python and does not prompt when PyManager is absent', async () => { + assert.deepStrictEqual(await manager.getEnvironments('global'), [native]); + sinon.assert.notCalled(prompt); + sinon.assert.notCalled(list); + }); + + test('delegates missing-Python acquisition without re-entering the environment API', async () => { + nativeRefresh.resolves([]); + const installed = environment(path.join(root, 'installed', 'python.exe'), '3.14.6'); + prompt.resolves(installed.execInfo.run.executable); + resolve.resolves(installed); + + assert.deepStrictEqual(await manager.getEnvironments('global'), [installed]); + + sinon.assert.calledOnceWithExactly(prompt, 'activation', manager.log); + sinon.assert.calledOnceWithExactly(setGlobal, installed.environmentPath.fsPath); + }); + + test('discovers manager-owned Python even when it has no registry result', async () => { + const managed = await runtime(); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + + const environments = await manager.getEnvironments('global'); + const discovered = environments.find((item) => normalizePath(item.execInfo.run.executable) === normalizePath(managed.executable)); + + assert.strictEqual(environments.length, 2); + assert.ok(discovered); + assert.strictEqual(discovered.envId.managerId, 'ms-python.python:system'); + assert.strictEqual(discovered.version, '3.14.6'); + sinon.assert.notCalled(prompt); + sinon.assert.notCalled(resolve); + }); + + test('exposes the first installed Python through PyManager when native resolution misses it', async () => { + const managed = await runtime(); + nativeRefresh.resolves([]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.onFirstCall().resolves([]).onSecondCall().resolves([managed]); + prompt.resolves(managed.executable); + + const environments = await manager.getEnvironments('global'); + + assert.strictEqual(environments.length, 1); + assert.strictEqual(environments[0].version, managed.version); + sinon.assert.calledOnceWithExactly(setGlobal, environments[0].environmentPath.fsPath); + sinon.assert.notCalled(resolve); + sinon.assert.notCalled(showError); + }); + + test('reports an installed Python that cannot be exposed by the environment API', async () => { + nativeRefresh.resolves([]); + prompt.resolves(path.join(root, 'unresolved', 'python.exe')); + + assert.deepStrictEqual(await manager.getEnvironments('global'), []); + + sinon.assert.calledOnce(showError); + sinon.assert.notCalled(setGlobal); + }); + + test('refreshes an in-place update instead of retaining an older native version', async () => { + const managed = await runtime('3.14.7'); + nativeRefresh.resolves([environment(managed.executable, '3.14.6')]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + + const environments = await manager.getEnvironments('global'); + + assert.strictEqual(environments.length, 1); + assert.strictEqual(environments[0].version, '3.14.7'); + }); + + test('retains previously discovered PyManager entries on transient listing failure', async () => { + const managed = await runtime(); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + list.rejects(new Error('manager busy')); + + await manager.refresh(undefined); + + const environments = await manager.getEnvironments('global'); + assert.ok(environments.some((item) => item.version === managed.version)); + assert.ok(environments.includes(native)); + }); + + test('removes previously discovered PyManager entries when the manager is no longer installed', async () => { + const managed = await runtime(); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + detect.resolves({ kind: 'absent' }); + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('global'), [native]); + }); + + test('does not discard native results when PyManager is unusable', async () => { + detect.resolves({ kind: 'unusable', executable: path.join(root, 'pymanager.exe'), error: new Error('denied') }); + assert.deepStrictEqual(await manager.getEnvironments('global'), [native]); + sinon.assert.notCalled(prompt); + }); + + test('removes a PyManager entry after a successful empty inventory', async () => { + const managed = await runtime(); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + list.resolves([]); + await manager.refresh(undefined); + assert.deepStrictEqual(await manager.getEnvironments('global'), [native]); + }); + + test('ignores launchers, relative executables and derived environments', async () => { + const managed = await runtime(); + await fs.outputFile(path.join(managed.prefix!, 'pyvenv.cfg'), 'home = base\n'); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([ + managed, + { ...managed, executable: 'python.exe' }, + { ...managed, executable: path.join(root, 'pymanager.exe') }, + ]); + assert.deepStrictEqual(await manager.getEnvironments('global'), [native]); + }); + + test('global creation refreshes and selects the verified newly installed version', async () => { + const managed = await runtime('3.14.7'); + nativeRefresh.resolves([environment(managed.executable, '3.14.6')]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + select.resolves(managed.executable); + + const created = await manager.create('global', undefined); + + assert.strictEqual(created?.version, '3.14.7'); + sinon.assert.calledOnceWithExactly(select, manager.log); + sinon.assert.calledOnceWithExactly(setGlobal, created!.environmentPath.fsPath); + sinon.assert.notCalled(resolve); + }); + + test('resolves an updated runtime directly rather than selecting stale inventory after refresh failure', async () => { + const executable = path.join(root, 'updated', 'python.exe'); + nativeRefresh.resolves([environment(executable, '3.14.6')]); + await manager.getEnvironments('global'); + nativeRefresh.rejects(new Error('native refresh failed')); + const updated = environment(executable, '3.14.7'); + select.resolves(executable); + resolve.resolves(updated); + + assert.strictEqual(await manager.create('global', undefined), updated); + + sinon.assert.calledOnce(resolve); + sinon.assert.calledOnceWithExactly(setGlobal, updated.environmentPath.fsPath); + sinon.assert.notCalled(showError); + }); + + test('does not select retained PyManager metadata after supplemental discovery fails during an update', async () => { + const managed = await runtime('3.14.6'); + nativeRefresh.resolves([]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + list.rejects(new Error('PyManager is temporarily unavailable')); + const updated = environment(managed.executable, '3.14.7'); + select.resolves(managed.executable); + resolve.resolves(updated); + + assert.strictEqual(await manager.create('global', undefined), updated); + + sinon.assert.calledOnceWithExactly(resolve, managed.executable, sinon.match.any, sinon.match.any, manager); + sinon.assert.calledOnceWithExactly(setGlobal, updated.environmentPath.fsPath); + sinon.assert.notCalled(showError); + }); + + test('reports unverifiable installation rather than returning retained PyManager metadata', async () => { + const managed = await runtime('3.14.6'); + nativeRefresh.resolves([]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + list.rejects(new Error('PyManager is temporarily unavailable')); + select.resolves(managed.executable); + + assert.strictEqual(await manager.create('global', undefined), undefined); + + sinon.assert.calledOnce(resolve); + sinon.assert.calledOnce(showError); + sinon.assert.notCalled(setGlobal); + }); + + test('keeps retained inventory and post-install selection consistent with an overlapping gated refresh', async () => { + const managed = await runtime('3.14.6'); + nativeRefresh.resolves([]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.onCall(0).resolves([managed]); + await manager.getEnvironments('global'); + list.onCall(1).rejects(new Error('Temporary supplemental lookup failure')); + list.onCall(2).resolves([{ ...managed, version: '3.14.7' }]); + const updated = environment(managed.executable, '3.14.7'); + select.resolves(managed.executable); + resolve.resolves(updated); + const postRefreshLoadStarted = createDeferred(); + const releasePostRefreshLoad = createDeferred(); + const inspectionStarted = createDeferred(); + const releaseInspection = createDeferred(); + globalSelection.onCall(1).callsFake(async () => { + postRefreshLoadStarted.resolve(); + await releasePostRefreshLoad.promise; + return undefined; + }); + sandbox.stub(nativeFs, 'realpath').callThrough().withArgs(managed.executable).callsFake(async () => { + inspectionStarted.resolve(); + await releaseInspection.promise; + return managed.executable; + }); + + const creation = manager.create('global', undefined); + let overlapping: Promise | undefined; + try { + await Promise.race([ + postRefreshLoadStarted.promise, + creation.then(() => { throw new Error('Creation completed before reaching the refresh gate'); }), + ]); + overlapping = manager.refresh(undefined); + await new Promise((done) => setImmediate(done)); + assert.strictEqual(list.callCount, 2, 'The next refresh must not change freshness during selection'); + releasePostRefreshLoad.resolve(); + assert.strictEqual(await creation, updated); + await inspectionStarted.promise; + assert.strictEqual((await manager.getEnvironments('global'))[0].version, '3.14.7'); + releaseInspection.resolve(); + await overlapping; + assert.strictEqual((await manager.getEnvironments('global'))[0].version, '3.14.7'); + sinon.assert.calledOnce(resolve); + } finally { + releasePostRefreshLoad.resolve(); + releaseInspection.resolve(); + await Promise.allSettled([creation, ...(overlapping ? [overlapping] : [])]); + } + }); + + test('refreshes metadata for a project already selecting the updated runtime without rewriting its selection', async () => { + const managed = await runtime('3.14.6'); + const project = { name: 'workspace', uri: Uri.file(path.join(root, 'workspace')) }; + getProjects.returns([project]); + workspaceSelection.resolves(managed.executable); + const writeWorkspace = sandbox.stub(cache, 'setSystemEnvForWorkspace').resolves(); + nativeRefresh.resolves([]); + detect.resolves({ kind: 'available', executable: path.join(root, 'pymanager.exe') }); + list.resolves([managed]); + await manager.getEnvironments('global'); + assert.strictEqual((await manager.get(project.uri))?.version, '3.14.6'); + list.rejects(new Error('PyManager is temporarily unavailable')); + const updated = environment(managed.executable, '3.14.7'); + select.resolves(managed.executable); + resolve.resolves(updated); + + await manager.create('global', undefined); + + assert.strictEqual(await manager.get(project.uri), updated); + sinon.assert.notCalled(writeWorkspace); + }); + + test('cancelling global creation changes no selection or inventory', async () => { + assert.strictEqual(await manager.create('global', undefined), undefined); + sinon.assert.notCalled(nativeRefresh); + sinon.assert.notCalled(setGlobal); + }); + + test('reports unresolved global creation instead of silently returning after installation', async () => { + select.resolves(path.join(root, 'unresolved', 'python.exe')); + + assert.strictEqual(await manager.create('global', undefined), undefined); + + sinon.assert.calledOnce(showError); + sinon.assert.notCalled(setGlobal); + }); +}); diff --git a/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts b/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts index 8409cb002..66cb25918 100644 --- a/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts +++ b/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts @@ -8,9 +8,9 @@ import { LogOutputChannel, Uri } from 'vscode'; import { EnvironmentChangeKind, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; import * as windowApis from '../../../common/window.apis'; import * as sysCache from '../../../managers/builtin/cache'; +import * as pythonInstaller from '../../../managers/builtin/pythonInstaller'; import { SysPythonManager } from '../../../managers/builtin/sysPythonManager'; import * as sysUtils from '../../../managers/builtin/utils'; -import * as uvPythonInstaller from '../../../managers/builtin/uvPythonInstaller'; import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; suite('SysPythonManager saved selections', () => { @@ -66,8 +66,7 @@ suite('SysPythonManager saved selections', () => { }); test('installing an already-installed version reuses the listed interpreter', async () => { - sinon.stub(uvPythonInstaller, 'selectPythonVersionToInstall').resolves('3.13'); - sinon.stub(uvPythonInstaller, 'installPythonWithUv').resolves(interpreter); + sinon.stub(pythonInstaller, 'selectAndInstallPython').resolves(interpreter); sinon.stub(sysCache, 'setSystemEnvForGlobal').resolves(); const manager = createManager(); await manager.initialize(); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 12bf1c7b3..1e13d0751 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -20,6 +20,8 @@ import * as windowApis from '../../../common/window.apis'; import * as envCommands from '../../../features/envCommands'; import { VenvManager } from '../../../managers/builtin/venvManager'; import * as venvUtils from '../../../managers/builtin/venvUtils'; +import * as pythonInstaller from '../../../managers/builtin/pythonInstaller'; +import * as builtinUtils from '../../../managers/builtin/utils'; import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; @@ -62,6 +64,8 @@ suite('VenvManager.create - orchestration', () => { let executeCommandStub: sinon.SinonStub; let quickCreateVenvStub: sinon.SinonStub; let showErrorStub: sinon.SinonStub; + let installPythonStub: sinon.SinonStub; + let resolveInstalledStub: sinon.SinonStub; let envDir: string; let pythonPath: string; let tmpRoot: string; @@ -71,6 +75,8 @@ suite('VenvManager.create - orchestration', () => { quickCreateVenvStub = sinon.stub(venvUtils, 'quickCreateVenv'); sinon.stub(envCommands, 'findParentIfFile').callsFake(async (value: string) => value); showErrorStub = sinon.stub(windowApis, 'showErrorMessage'); + installPythonStub = sinon.stub(pythonInstaller, 'promptInstallPython').resolves(undefined); + resolveInstalledStub = sinon.stub(builtinUtils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); executeCommandStub = sinon.stub(commandApis, 'executeCommand').resolves(); tmpRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'venvmgr-')); @@ -178,6 +184,73 @@ suite('VenvManager.create - orchestration', () => { assert.strictEqual((manager as any).skipWatcherRefresh, false); }); + + test('stops without an extra error or environment creation when Python installation is declined', async () => { + const manager = createManager(); + + assert.strictEqual(await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), undefined); + + sinon.assert.calledOnce(installPythonStub); + sinon.assert.notCalled(createPythonVenvStub); + sinon.assert.notCalled(quickCreateVenvStub); + sinon.assert.notCalled(showErrorStub); + }); + + test('uses the exact returned base when discovery refresh fails', async () => { + const basePath = path.join(tmpRoot, 'base', 'python.exe'); + const base: PythonEnvironment = { + ...createMockPythonEnvironment({ + envPath: basePath, + sysPrefix: path.dirname(basePath), + version: '3.14.6', + managerId: 'ms-python.python:system', + }), + execInfo: { run: { executable: basePath } }, + }; + const manager = createManager({ refreshEnvironments: sinon.stub().rejects(new Error('refresh unavailable')) }); + installPythonStub.resolves(basePath); + resolveInstalledStub.resolves(base); + quickCreateVenvStub.resolves({ environment: createdEnvironment() }); + + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }); + + assert.strictEqual(quickCreateVenvStub.firstCall.args[4], base); + sinon.assert.notCalled(showErrorStub); + }); + + test('prefers refreshed metadata for an in-place update over a stale resolver result', async () => { + const basePath = path.join(tmpRoot, 'base', 'python.exe'); + const base: PythonEnvironment = { + ...createMockPythonEnvironment({ + envPath: basePath, + sysPrefix: path.dirname(basePath), + version: '3.14.7', + managerId: 'ms-python.python:system', + }), + execInfo: { run: { executable: basePath } }, + }; + const globals = sinon.stub().onFirstCall().resolves([]).onSecondCall().resolves([base]); + const manager = createManager({ getEnvironments: globals }); + installPythonStub.resolves(basePath); + resolveInstalledStub.resolves({ ...base, version: '3.14.6' }); + quickCreateVenvStub.resolves({ environment: createdEnvironment() }); + + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }); + + assert.strictEqual(quickCreateVenvStub.firstCall.args[4], base); + sinon.assert.notCalled(resolveInstalledStub); + }); + + test('does not create a venv when the installed base cannot be resolved', async () => { + const manager = createManager(); + installPythonStub.resolves(path.join(tmpRoot, 'missing', 'python.exe')); + + assert.strictEqual(await manager.create(Uri.file(path.join(tmpRoot, 'project')), { quickCreate: true }), undefined); + + sinon.assert.calledOnce(showErrorStub); + sinon.assert.notCalled(quickCreateVenvStub); + sinon.assert.notCalled(createPythonVenvStub); + }); }); suite('VenvManager.remove - orchestration', () => { diff --git a/src/test/managers/common/packageWatcher.unit.test.ts b/src/test/managers/common/packageWatcher.unit.test.ts index 28c6b55cb..4f851d887 100644 --- a/src/test/managers/common/packageWatcher.unit.test.ts +++ b/src/test/managers/common/packageWatcher.unit.test.ts @@ -2,8 +2,11 @@ // Licensed under the MIT License. import * as assert from 'assert'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; -import { Disposable, EventEmitter, LogOutputChannel, RelativePattern, Terminal, Uri } from 'vscode'; +import { CancellationError, Disposable, EventEmitter, LogOutputChannel, RelativePattern, Terminal, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, PackageManager, @@ -18,8 +21,9 @@ import type { EnvironmentManagers } from '../../../features/envManagers'; import { InternalPackageManager } from '../../../managers/common/registeredManagers'; import { PackageWatcherTerminalActivation, - registerPackageWatchers, + registerPackageWatchers as registerPackageWatchersCore, watchPackageChangesForEnvironment, + withPackageWatchersPaused, } from '../../../managers/common/packageWatcher'; suite('Package Watcher', () => { @@ -32,9 +36,17 @@ suite('Package Watcher', () => { activated: boolean; }>; let mockTerminalActivation: PackageWatcherTerminalActivation; + let registrations: Disposable[]; + + function registerPackageWatchers(...args: Parameters): Disposable { + const disposable = registerPackageWatchersCore(...args); + registrations.push(disposable); + return disposable; + } setup(() => { sandbox = sinon.createSandbox(); + registrations = []; mockLogOutputChannel = { error: sandbox.stub(), warn: sandbox.stub(), @@ -55,6 +67,7 @@ suite('Package Watcher', () => { }); teardown(() => { + registrations.forEach((registration) => registration.dispose()); terminalActivationChanges.dispose(); sandbox.restore(); }); @@ -103,6 +116,191 @@ suite('Package Watcher', () => { }; } + suite('runtime installation coordination', () => { + let changes: EventEmitter; + let environment: PythonEnvironment; + let root: string; + + setup(async () => { + root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pim-watcher-unit-'))); + await Promise.all(['pim-runtime', 'other-runtime', 'replacement-runtime'] + .map((directory) => fs.mkdir(path.join(root, directory)))); + changes = new EventEmitter(); + environment = createMockEnvironment({ sysPrefix: path.join(root, 'pim-runtime') }); + createFileSystemWatcherStub.callsFake(() => createMockWatcher()); + const packageManager = new InternalPackageManager('pip', createMockPackageManager() as PackageManager); + const managers = { + onDidChangeActiveEnvironment: changes.event, + getPackageManager: sandbox.stub().returns(packageManager), + } as unknown as EnvironmentManagers; + registerPackageWatchers(managers, mockTerminalActivation, mockLogOutputChannel as LogOutputChannel); + }); + + teardown(async () => { + changes.dispose(); + await fs.rm(root, { recursive: true, force: true }); + }); + + test('releases the shared runtime watcher before installation and restores it once', async () => { + const terminal = { name: 'active-runtime' } as Terminal; + changes.fire({ uri: undefined, new: environment, old: undefined }); + terminalActivationChanges.fire({ terminal, environment, activated: true }); + const watcher = createFileSystemWatcherStub.firstCall.returnValue; + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + sinon.assert.calledOnce(watcher.dispose); + assert.strictEqual(createFileSystemWatcherStub.callCount, 1); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2, 'Shared consumers must restore one watcher'); + }); + + for (const error of [new Error('install failed'), new CancellationError()]) { + test(`restores runtime watchers after ${error instanceof CancellationError ? 'cancellation' : 'failure'}`, async () => { + changes.fire({ uri: undefined, new: environment, old: undefined }); + + await assert.rejects(withPackageWatchersPaused(environment.sysPrefix, async () => { throw error; })); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + sinon.assert.calledOnce(createFileSystemWatcherStub.firstCall.returnValue.dispose); + }); + } + + test('does not resume until every nested installation operation has finished', async () => { + changes.fire({ uri: undefined, new: environment, old: undefined }); + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + await withPackageWatchersPaused(environment.sysPrefix, async () => { + assert.strictEqual(createFileSystemWatcherStub.callCount, 1); + }); + assert.strictEqual(createFileSystemWatcherStub.callCount, 1); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + test('keeps watching an unrelated runtime without broadening watch roots', async () => { + const other = createMockEnvironment({ + envId: { id: 'other', managerId: 'test-manager' }, + sysPrefix: path.join(root, 'other-runtime'), + }); + changes.fire({ uri: undefined, new: environment, old: undefined }); + changes.fire({ uri: Uri.file(path.resolve('project')), new: other, old: undefined }); + const unrelatedWatcher = createFileSystemWatcherStub.secondCall.returnValue; + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + sinon.assert.notCalled(unrelatedWatcher.dispose); + }); + + sinon.assert.notCalled(unrelatedWatcher.dispose); + assert.strictEqual(createFileSystemWatcherStub.callCount, 3); + }); + + test('does not resurrect an old selection changed during installation', async () => { + const replacement = createMockEnvironment({ + envId: { id: 'replacement', managerId: 'test-manager' }, + sysPrefix: path.join(root, 'replacement-runtime'), + }); + changes.fire({ uri: undefined, new: environment, old: undefined }); + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + changes.fire({ uri: undefined, new: replacement, old: environment }); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + test('defers watchers for a runtime selected while installation is already in progress', async () => { + await withPackageWatchersPaused(environment.sysPrefix, async () => { + changes.fire({ uri: undefined, new: environment, old: undefined }); + sinon.assert.notCalled(createFileSystemWatcherStub); + }); + + sinon.assert.calledOnce(createFileSystemWatcherStub); + }); + + test('does not restore a terminal watcher after it is deactivated during installation', async () => { + const terminal = { name: 'active-runtime' } as Terminal; + terminalActivationChanges.fire({ terminal, environment, activated: true }); + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + terminalActivationChanges.fire({ terminal, environment, activated: false }); + }); + + sinon.assert.calledOnce(createFileSystemWatcherStub); + }); + + test('releases a watcher whose prefix has a trailing separator before mutation begins', async () => { + const selected = { ...environment, sysPrefix: environment.sysPrefix + path.sep }; + changes.fire({ uri: undefined, new: selected, old: undefined }); + const watcher = createFileSystemWatcherStub.firstCall.returnValue; + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + sinon.assert.calledOnce(watcher.dispose); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + test('releases a junction or symlink-spelled watcher before mutating its physical runtime', async () => { + const alias = path.join(root, 'runtime-alias'); + await fs.symlink(environment.sysPrefix, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const selected = { ...environment, sysPrefix: alias }; + changes.fire({ uri: undefined, new: selected, old: undefined }); + const watcher = createFileSystemWatcherStub.firstCall.returnValue; + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + sinon.assert.calledOnce(watcher.dispose); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + test('uses the native physical identity for an alternate short-path spelling', async () => { + const shortPrefix = path.join(root, 'PYTHON~1'); + const realpath = sandbox.stub(fs, 'realpath').callThrough(); + realpath.withArgs(shortPrefix).resolves(environment.sysPrefix); + changes.fire({ uri: undefined, new: { ...environment, sysPrefix: shortPrefix }, old: undefined }); + const watcher = createFileSystemWatcherStub.firstCall.returnValue; + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + sinon.assert.calledOnce(watcher.dispose); + }); + + sinon.assert.calledWithExactly(realpath, shortPrefix); + }); + + test('bounds an unreadable prefix lookup and pauses its watcher conservatively', async () => { + sandbox.stub(fs, 'realpath').returns(new Promise(() => undefined)); + const clock = sandbox.useFakeTimers(); + changes.fire({ uri: undefined, new: environment, old: undefined }); + const watcher = createFileSystemWatcherStub.firstCall.returnValue; + let started = false; + const installation = withPackageWatchersPaused(environment.sysPrefix, async () => { + started = true; + sinon.assert.calledOnce(watcher.dispose); + }); + assert.strictEqual(started, false); + + await clock.tickAsync(1000); + await installation; + + assert.strictEqual(started, true); + sinon.assert.calledOnce(mockLogOutputChannel.warn as sinon.SinonStub); + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + test('does not recreate watchers after the registration is disposed during installation', async () => { + changes.fire({ uri: undefined, new: environment, old: undefined }); + + await withPackageWatchersPaused(environment.sysPrefix, async () => { + registrations[0].dispose(); + }); + + sinon.assert.calledOnce(createFileSystemWatcherStub); + }); + }); + suite('watchPackageChangesForEnvironment', () => { test('should create file system watchers for watch targets', () => { const mockWatcher = createMockWatcher(); @@ -468,8 +666,8 @@ suite('Package Watcher', () => { assert.ok((mockWatcher.dispose as sinon.SinonStub).called, 'Should dispose watcher after the final scope'); }); - test('should use separate watchers for project-bound package managers', () => { - createFileSystemWatcherStub.returns(createMockWatcher()); + test('should preserve separate project-bound watchers across runtime installation', async () => { + createFileSystemWatcherStub.callsFake(() => createMockWatcher()); const environmentChanges = new EventEmitter(); const firstScope = Uri.file('workspace-one'); const secondScope = Uri.file('workspace-two'); @@ -489,13 +687,23 @@ suite('Package Watcher', () => { .stub() .callsFake((scope) => (scope === firstScope ? firstPackageManager : secondPackageManager)), } as unknown as EnvironmentManagers; - const env = createMockEnvironment(); + const env = createMockEnvironment({ sysPrefix: await fs.realpath(process.cwd()) }); registerPackageWatchers(envManagers, mockTerminalActivation, mockLogOutputChannel as LogOutputChannel); environmentChanges.fire({ uri: firstScope, new: env, old: undefined }); environmentChanges.fire({ uri: secondScope, new: env, old: undefined }); assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + const firstWatcher = createFileSystemWatcherStub.firstCall.returnValue; + const secondWatcher = createFileSystemWatcherStub.secondCall.returnValue; + + await withPackageWatchersPaused(env.sysPrefix, async () => { + sinon.assert.calledOnce(firstWatcher.dispose); + sinon.assert.calledOnce(secondWatcher.dispose); + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); + }); + + assert.strictEqual(createFileSystemWatcherStub.callCount, 4, 'Should restore both project-bound watchers'); }); test('should stop watching an environment when the active environment changes', () => { diff --git a/src/test/managers/fastPath.get.unit.test.ts b/src/test/managers/fastPath.get.unit.test.ts index 62a48a88b..c7142ed05 100644 --- a/src/test/managers/fastPath.get.unit.test.ts +++ b/src/test/managers/fastPath.get.unit.test.ts @@ -18,6 +18,7 @@ import * as windowApis from '../../common/window.apis'; import * as sysCache from '../../managers/builtin/cache'; import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; import * as sysUtils from '../../managers/builtin/utils'; +import * as pymanager from '../../managers/builtin/pymanagerPythonInstaller'; import { VenvManager } from '../../managers/builtin/venvManager'; import * as venvUtils from '../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; @@ -284,6 +285,7 @@ suite('Manager get() fast path', () => { setup(() => { sandbox = sinon.createSandbox(); + sandbox.stub(pymanager, 'detectPymanager').resolves({ kind: 'absent' }); sandbox.stub(windowApis, 'withProgress').callsFake((_opts, cb) => cb(undefined as never, undefined as never)); // fastPath.ts now does a real fs.access on the persisted path; these tests use // synthetic paths that don't exist on disk, so pretend every path is present.