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.