From d58cf31a632d0806f584fbaadc1262e23bb83011 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 23 Jul 2026 15:19:51 -0700 Subject: [PATCH 1/2] Add uv fallback for inline script environments Add consent-gated uv installation when no installed interpreter satisfies a script. Coalesce matching installs, skip prompts for quick create, and directly resolve a successful installation when discovery is stale or unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91 --- src/common/localize.ts | 7 + .../builtin/inlineScript/envManager.ts | 240 +++++++- src/managers/builtin/uvPythonInstaller.ts | 28 +- .../inlineScript/envManager.unit.test.ts | 542 +++++++++++++++++- .../builtin/uvPythonInstaller.unit.test.ts | 27 + 5 files changed, 824 insertions(+), 20 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index c18e7340..43266c33 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -238,6 +238,7 @@ export namespace UvInstallStrings { 'No Python found. Would you like to install uv and use it to install Python? This will download and run an installer from https://astral.sh.', ); export const installPython = l10n.t('Install Python'); + export const installUv = l10n.t('Install uv'); export const installUvAndPython = l10n.t('Install uv and Python'); export function installPythonVersion(version: string): string { return l10n.t('Install Python {0}', version); @@ -281,6 +282,12 @@ export namespace UvInstallStrings { 'No Python installation is available for this script. Would you like to install uv and use it to install Python? This will download and run an installer from https://astral.sh.', ); } + export function inlineScriptInstallUvForVersionLookupPrompt(requiresPython: string): string { + return l10n.t( + 'No installed Python satisfies this script\'s requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.', + requiresPython, + ); + } export const installingUv = l10n.t('Installing uv...'); export const installingPython = l10n.t('Installing Python via uv...'); export const installComplete = l10n.t('Python installed successfully'); diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 345bb127..717da084 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -3,6 +3,7 @@ import * as fs from 'fs-extra'; import * as path from 'path'; +import { clean as cleanPep440, satisfies as satisfiesPep440 } from '@renovatebot/pep440'; import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, @@ -31,12 +32,8 @@ import { resolveCacheEntryPath, writeMetaJson, } from '../../../common/inlineScript/cacheLayout'; -import { pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; -import { - InlineScriptMetadata, - matchesPythonVersion, - readInlineScriptMetadataFromFile, -} from '../../../common/inlineScript/metadata'; +import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; +import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata'; import { CONDA_MANAGER_ID, PYENV_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../../common/constants'; import { acquireFileLock, AcquiredFileLock } from '../../../common/lockfile.apis'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; @@ -44,6 +41,8 @@ import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { NativePythonFinder } from '../../common/nativePythonFinder'; +import { resolveSystemPythonEnvironmentPath } from '../utils'; +import * as uvPythonInstaller from '../uvPythonInstaller'; import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils'; const BASE_INTERPRETER_MANAGER_IDS = new Set([ @@ -79,6 +78,8 @@ type CacheEntryInspection = /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingCreations = new Map>(); + private readonly directlyResolvedBaseInterpreters = new Map(); + private baseInterpreterInstallationQueue: Promise = Promise.resolve(); private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -131,9 +132,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - const selectedBase = await this.selectBaseInterpreter(metadata); + let selectedBase = await this.selectBaseInterpreter(metadata); + if (!selectedBase && options?.quickCreate !== true) { + selectedBase = await this.installAndSelectBaseInterpreter(metadata); + } if (!selectedBase) { - this.log.warn(`No installed Python satisfies the inline-script requirements for ${scriptUri.fsPath}.`); + this.log.warn(`No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`); return undefined; } @@ -192,12 +196,24 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise { - const globalEnvironments = await this.api.getEnvironments('global'); - const reported = globalEnvironments.filter( - (environment) => - BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) && - (environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'), - ); + let globalEnvironments: readonly PythonEnvironment[] = []; + try { + globalEnvironments = await this.api.getEnvironments('global'); + } catch (error) { + this.log.warn(`Unable to query discovered base interpreters: ${getErrorMessage(error)}`); + } + const reported = [ + ...globalEnvironments.filter( + (environment) => + BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) && + (environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'), + ), + ...[...this.directlyResolvedBaseInterpreters.values()].filter( + (environment) => + !metadata.requiresPython || + this.matchesInstallConstraint(metadata.requiresPython, environment.version), + ), + ]; const derivedChecks = await Promise.all( reported.map(async (environment) => { if (!path.isAbsolute(environment.sysPrefix)) { @@ -214,10 +230,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); let candidates = derivedChecks .filter((candidate) => !candidate.derived) - .map((candidate) => candidate.environment); + .map((candidate) => candidate.environment) + .filter( + (candidate) => + !metadata.requiresPython || + this.matchesInstallConstraint(metadata.requiresPython, candidate.version), + ); while (candidates.length > 0) { - const environment = pickCompatibleInterpreter(candidates, metadata.requiresPython); + const environment = pickCompatibleInterpreter(candidates, undefined); if (!environment) { return undefined; } @@ -239,6 +260,191 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + private async installAndSelectBaseInterpreter( + metadata: InlineScriptMetadata, + ): Promise { + const run = this.baseInterpreterInstallationQueue.then(() => + this.installAndSelectBaseInterpreterSerially(metadata), + ); + this.baseInterpreterInstallationQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async installAndSelectBaseInterpreterSerially( + metadata: InlineScriptMetadata, + ): Promise { + const existing = await this.selectBaseInterpreter(metadata); + if (existing) { + return existing; + } + + const requiresPython = metadata.requiresPython?.trim() || undefined; + const lowerBound = extractLowerBoundVersion(requiresPython); + const version = await this.selectInstallablePythonVersion(requiresPython, lowerBound); + if (requiresPython && !version) { + this.log.warn( + 'Cannot install a Python for this inline script because no compatible install version could be selected.', + ); + return undefined; + } + + const installedPath = await this.installPythonAndRefresh(requiresPython, version); + if (!installedPath) { + return undefined; + } + + let selected: SelectedBaseInterpreter | undefined; + try { + selected = await this.selectBaseInterpreter(metadata); + } catch (error) { + this.log.warn( + `Unable to refresh base-interpreter discovery after installing Python: ${getErrorMessage(error)}`, + ); + } + if (!selected) { + const resolved = await resolveSystemPythonEnvironmentPath( + installedPath, + this.nativeFinder, + this.api, + this.baseManager, + ); + const executable = resolved?.execInfo?.run.executable; + if (resolved && executable && pickCompatibleInterpreter([resolved], metadata.requiresPython)) { + try { + const canonicalPath = await fs.realpath(executable); + if (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) { + this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved); + selected = { + environment: resolved, + canonicalPath, + }; + } + } catch (error) { + this.log.warn( + `Unable to resolve the Python installed for an inline script at ${executable}: ${getErrorMessage(error)}`, + ); + } + } + } + if (!selected) { + this.log.warn( + 'Python was installed for an inline script, but no compatible base interpreter was discovered after refreshing environments.', + ); + } + return selected; + } + + private async selectInstallablePythonVersion( + requiresPython: string | undefined, + lowerBound: string | undefined, + ): Promise { + if (!requiresPython) { + return lowerBound; + } + const prereleaseLowerBound = this.extractPrereleaseLowerBound(requiresPython); + if (prereleaseLowerBound) { + return prereleaseLowerBound; + } + const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined; + if (lowerBound && lowerBoundRelease?.[0] === 3) { + if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { + return lowerBound; + } + if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { + return lowerBound; + } + } + + let available: uvPythonInstaller.UvPythonVersion[]; + try { + if (!(await uvPythonInstaller.ensureUvForInlineScriptVersionLookup(requiresPython, this.log))) { + return undefined; + } + available = await uvPythonInstaller.getAvailablePythonVersions(); + } catch (error) { + this.log.warn(`Unable to query Python versions available from uv: ${getErrorMessage(error)}`); + return undefined; + } + return available + .filter( + (candidate) => + candidate.implementation === 'cpython' && + candidate.variant === 'default' && + candidate.version_parts.major === 3 && + this.matchesInstallConstraint(requiresPython, candidate.version), + ) + .sort((left, right) => { + const leftRelease = parseReleaseSegments(left.version); + const rightRelease = parseReleaseSegments(right.version); + if (!leftRelease || !rightRelease) { + return 0; + } + return compareReleaseSegments(rightRelease, leftRelease); + })[0]?.version; + } + + private matchesInstallConstraint(requiresPython: string, version: string): boolean { + try { + return satisfiesPep440(version, requiresPython, { + prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test( + requiresPython, + ), + }); + } catch (error) { + this.log.warn(`Unable to evaluate requires-python '${requiresPython}': ${getErrorMessage(error)}`); + return false; + } + } + + private extractPrereleaseLowerBound(requiresPython: string): string | undefined { + return requiresPython + .split(',') + .map((clause) => + clause + .trim() + .match( + /^(?:>=|==|~=)\s*(\d+(?:\.\d+)*(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|[._-]?dev[._-]?\d+))$/i, + )?.[1], + ) + .map((version) => (version ? cleanPep440(version) : undefined)) + .filter((version): version is string => !!version) + .find((version) => this.matchesInstallConstraint(requiresPython, version)); + } + + private async installPythonAndRefresh( + requiresPython: string | undefined, + version: string | undefined, + ): Promise { + let installedPath: string | undefined; + try { + installedPath = await uvPythonInstaller.promptInstallPythonViaUv('inlineScript', this.log, { + requiresPython, + version, + }); + if (!installedPath) { + this.log.warn( + 'Python installation for inline-script environment creation was declined or did not complete.', + ); + return undefined; + } + } catch (error) { + this.log.error(`Failed to install Python for an inline script: ${getErrorMessage(error)}`); + return undefined; + } + + try { + await this.api.refreshEnvironments(undefined); + } catch (error) { + this.log.warn( + `Python was installed for an inline script, but environment discovery could not be refreshed: ${getErrorMessage(error)}`, + ); + } + return installedPath; + } + private async createOrReuseEnvironment({ cacheKey, packages, @@ -363,7 +569,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { kind: 'stale' }; } const requiresPython = metadata.requiresPython?.trim(); - if (requiresPython && !matchesPythonVersion(requiresPython, environment.version)) { + if (requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version)) { return { kind: 'stale' }; } diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index ef87137c..c0fc90e6 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -26,7 +26,7 @@ const MAX_PROMPT_DETAIL_LENGTH = 120; const TASK_TIMEOUT_MS = 5 * 60 * 1000; // Accept only numeric release segments before forwarding script-controlled input to uv. -const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*$/; +const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.dev\d+)?$/i; // Remove C0/C1 controls and Unicode zero-width/bidirectional formatting characters // before displaying script-controlled text in a modal prompt. @@ -185,6 +185,32 @@ export async function installUv(_log?: LogOutputChannel): Promise { return success; } +export async function ensureUvForInlineScriptVersionLookup( + requiresPython: string, + log?: LogOutputChannel, +): Promise { + if (await isUvInstalled(log)) { + return true; + } + const displayedRequirement = sanitizePromptDetail(requiresPython); + if (!displayedRequirement) { + return false; + } + const selection = await showInformationMessage( + UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt(displayedRequirement), + { modal: true }, + UvInstallStrings.installUv, + ); + if (selection !== UvInstallStrings.installUv || !(await installUv(log))) { + return false; + } + if (await isUvInstalled(log)) { + return true; + } + showErrorMessage(UvInstallStrings.uvInstallRestartRequired); + return false; +} + /** * Gets the path to the uv-managed Python installation. * Uses `uv python list --only-installed --managed-python` to find only uv-installed Pythons. diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 4baf669f..223adc3d 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -15,6 +15,8 @@ import * as lockfileApis from '../../../../common/lockfile.apis'; import { isWindows } from '../../../../common/utils/platformUtils'; import { getVenvPythonPath } from '../../../../common/utils/virtualEnvironment'; import { InlineScriptEnvManager } from '../../../../managers/builtin/inlineScript/envManager'; +import * as builtinUtils from '../../../../managers/builtin/utils'; +import * as uvPythonInstaller from '../../../../managers/builtin/uvPythonInstaller'; import * as venvUtils from '../../../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../../../managers/common/nativePythonFinder'; @@ -62,24 +64,44 @@ function makeEnvironment( }; } +function makeUvPythonVersion(version: string): uvPythonInstaller.UvPythonVersion { + const [major, minor, patch] = version.match(/\d+/g)!.map(Number); + return { + key: `cpython-${version}`, + version, + version_parts: { major, minor, patch }, + path: null, + url: null, + os: 'windows', + variant: 'default', + implementation: 'cpython', + arch: 'x86_64', + }; +} + const venvPythonPath = getVenvPythonPath; suite('InlineScriptEnvManager', () => { let api: PythonEnvironmentApi; let apiGetEnvironmentsStub: sinon.SinonStub; + let apiRefreshEnvironmentsStub: sinon.SinonStub; let baseEnvironment: PythonEnvironment; let baseExecutable: string; let baseManager: EnvironmentManager; let computeCacheKeyStub: sinon.SinonStub; let createWithProgressStub: sinon.SinonStub; + let getAvailablePythonVersionsStub: sinon.SinonStub; + let ensureUvForVersionLookupStub: sinon.SinonStub; let globalStorageUri: Uri; let lockStub: sinon.SinonStub; let manager: InlineScriptEnvManager; let nativeFinder: NativePythonFinder; + let promptInstallPythonViaUvStub: sinon.SinonStub; let readMetadataStub: sinon.SinonStub; let inspectMetaStub: sinon.SinonStub; let retainLockStub: sinon.SinonStub; let releaseLockStub: sinon.SinonStub; + let resolveSystemPythonStub: sinon.SinonStub; let resolveVenvStub: sinon.SinonStub; let tempRoot: string; let baseInterpreterStatusStub: sinon.SinonStub; @@ -93,12 +115,21 @@ suite('InlineScriptEnvManager', () => { baseEnvironment = makeEnvironment('ms-python.python:system', '3.12.4', baseExecutable); apiGetEnvironmentsStub = sinon.stub().resolves([baseEnvironment]); - api = { getEnvironments: apiGetEnvironmentsStub } as unknown as PythonEnvironmentApi; + apiRefreshEnvironmentsStub = sinon.stub().resolves(); + api = { + getEnvironments: apiGetEnvironmentsStub, + refreshEnvironments: apiRefreshEnvironmentsStub, + } as unknown as PythonEnvironmentApi; nativeFinder = {} as NativePythonFinder; baseManager = {} as EnvironmentManager; readMetadataStub = sinon.stub(metadataReader, 'readInlineScriptMetadataFromFile').resolves(VALID_METADATA); computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').returns(CACHE_KEY); + getAvailablePythonVersionsStub = sinon.stub(uvPythonInstaller, 'getAvailablePythonVersions').resolves([]); + ensureUvForVersionLookupStub = sinon + .stub(uvPythonInstaller, 'ensureUvForInlineScriptVersionLookup') + .resolves(true); + promptInstallPythonViaUvStub = sinon.stub(uvPythonInstaller, 'promptInstallPythonViaUv'); inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').resolves({ kind: 'missing' }); baseInterpreterStatusStub = sinon.stub(cacheLayout, 'getBaseInterpreterStatus').resolves('available'); writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').resolves(); @@ -107,14 +138,16 @@ suite('InlineScriptEnvManager', () => { lockStub = sinon .stub(lockfileApis, 'acquireFileLock') .resolves({ release: releaseLockStub, retain: retainLockStub }); + resolveSystemPythonStub = sinon.stub(builtinUtils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { const envDir = args[6] as string; + const selectedBase = args[4] as PythonEnvironment; await fs.outputFile(getVenvPythonPath(envDir), ''); return { environment: makeEnvironment( 'ms-python.python:inline-script', - '3.12.4', + selectedBase.version, getVenvPythonPath(envDir), envDir, ), @@ -205,6 +238,17 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); + test('does not reapply release-only matching after strict PEP 440 filtering', async () => { + const finalRelease = makeEnvironment('ms-python.python:system', '3.15.0', baseExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '!=3.15.0rc2' }); + apiGetEnvironmentsStub.resolves([finalRelease]); + + assert.ok(await manager.create(scriptUri())); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], finalRelease); + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); + }); + test('excludes named conda environments even when they are newer than conda base', async () => { const condaNamed = makeEnvironment( 'ms-python.python:conda', @@ -281,6 +325,500 @@ suite('InlineScriptEnvManager', () => { }); }); + 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'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.2', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.13', + version: '3.13', + }); + sinon.assert.calledOnceWithExactly(apiRefreshEnvironmentsStub, undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 3); + assert.strictEqual(createWithProgressStub.firstCall.args[4], uvBase); + }); + + test('asks uv for the latest Python when requires-python is absent', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.14.0', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: undefined }); + apiGetEnvironmentsStub.onFirstCall().resolves([]); + apiGetEnvironmentsStub.onSecondCall().resolves([]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: undefined, + version: undefined, + }); + sinon.assert.calledOnceWithExactly(apiRefreshEnvironmentsStub, undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 3); + assert.strictEqual(createWithProgressStub.firstCall.args[4], uvBase); + }); + + test('does not mutate the cache when the user declines installation', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + promptInstallPythonViaUvStub.resolves(undefined); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + }); + + test('does not mutate the cache when installation fails', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + promptInstallPythonViaUvStub.rejects(new Error('uv failed')); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('directly resolves the installed interpreter when environment refresh fails', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.2', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.resolves([baseEnvironment]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + apiRefreshEnvironmentsStub.rejects(new Error('discovery failed')); + resolveSystemPythonStub.resolves(uvBase); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(apiRefreshEnvironmentsStub, undefined); + sinon.assert.calledOnceWithExactly( + resolveSystemPythonStub, + uvExecutable, + nativeFinder, + api, + baseManager, + ); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('directly resolves the installed interpreter when post-install discovery fails', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.2', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().rejects(new Error('discovery failed')); + promptInstallPythonViaUvStub.resolves(uvExecutable); + resolveSystemPythonStub.resolves(uvBase); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly( + resolveSystemPythonStub, + uvExecutable, + nativeFinder, + api, + baseManager, + ); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('selects an available uv release that satisfies exclusion clauses', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.3', uvExecutable); + readMetadataStub.resolves({ + ...VALID_METADATA, + requiresPython: '>=3.13.2,!=3.13.2', + }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + getAvailablePythonVersionsStub.resolves([ + { + key: 'cpython-3.13.2', + version: '3.13.2', + version_parts: { major: 3, minor: 13, patch: 2 }, + path: null, + url: null, + os: 'windows', + variant: 'default', + implementation: 'cpython', + arch: 'x86_64', + }, + { + key: 'cpython-3.13.3', + version: '3.13.3', + version_parts: { major: 3, minor: 13, patch: 3 }, + path: null, + url: null, + os: 'windows', + variant: 'default', + implementation: 'cpython', + arch: 'x86_64', + }, + ]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.13.2,!=3.13.2', + version: '3.13.3', + }); + assert.strictEqual(createWithProgressStub.firstCall.args[4], uvBase); + sinon.assert.calledOnceWithExactly( + ensureUvForVersionLookupStub, + '>=3.13.2,!=3.13.2', + manager.log, + ); + }); + + test('uses an explicit patch release when a minor selector could exceed the constraint', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.0', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13,<=3.13' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + getAvailablePythonVersionsStub.resolves([ + makeUvPythonVersion('3.13.3'), + makeUvPythonVersion('3.13.0'), + ]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.13,<=3.13', + version: '3.13.0', + }); + }); + + test('uses an advertised release for a bounded range instead of fabricating patch zero', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.11.14', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.11,<3.12' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + getAvailablePythonVersionsStub.resolves([makeUvPythonVersion('3.11.14')]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.11,<3.12', + version: '3.11.14', + }); + }); + + test('uses an exact requirement without needing an existing uv catalog', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '==3.13.1' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + getAvailablePythonVersionsStub.resolves([]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '==3.13.1', + version: '3.13.1', + }); + assert.strictEqual(getAvailablePythonVersionsStub.callCount, 0); + }); + + test('does not select a uv prerelease unless requires-python permits it', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.14.2', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.14,<3.16' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + getAvailablePythonVersionsStub.resolves([ + makeUvPythonVersion('3.15.0a6'), + makeUvPythonVersion('3.14.2'), + ]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.14,<3.16', + version: '3.14.2', + }); + }); + + test('installs an explicitly permitted prerelease lower bound', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.15.0a1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.15.0a1,<3.16' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '>=3.15.0a1,<3.16', + version: '3.15.0a1', + }); + }); + + test('normalizes a PEP 440 prerelease alias before installation', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.15.0rc1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '==3.15.0c1' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '==3.15.0c1', + version: '3.15.0rc1', + }); + }); + + test('does not prompt when requires-python has no safe lower bound', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '<3.13' }); + apiGetEnvironmentsStub.resolves([ + makeEnvironment('ms-python.python:system', '3.13.0', baseExecutable), + ]); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + for (const [description, refreshedEnvironments] of [ + ['the installed interpreter is not compatible', [baseEnvironment]], + ['no installed interpreter is reported', []], + ] as const) { + test(`does not build when ${description} after refresh`, async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves(refreshedEnvironments); + promptInstallPythonViaUvStub.resolves(baseExecutable); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + + sinon.assert.calledOnceWithExactly(apiRefreshEnvironmentsStub, undefined); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + } + + test('does not prompt when a compatible installed interpreter is available', async () => { + assert.ok(await manager.create(scriptUri())); + + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + }); + + test('does not prompt during quick create', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.resolves([baseEnvironment]); + + assert.strictEqual(await manager.create(scriptUri(), { quickCreate: true }), undefined); + + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); + }); + + test('coalesces simultaneous fallback requests for the same Python version', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + + let isInstalled = false; + let initialQueries = 0; + let signalSecondInitialQuery: (() => void) | undefined; + const secondInitialQuery = new Promise((resolve) => { + signalSecondInitialQuery = resolve; + }); + apiGetEnvironmentsStub.callsFake(async () => { + if (isInstalled) { + return [uvBase]; + } + initialQueries += 1; + if (initialQueries === 2) { + signalSecondInitialQuery!(); + } + return []; + }); + + let releaseInstall: (() => void) | undefined; + let signalPrompt: (() => void) | undefined; + const promptShown = new Promise((resolve) => { + signalPrompt = resolve; + }); + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + promptInstallPythonViaUvStub.callsFake(async () => { + signalPrompt!(); + await installGate; + isInstalled = true; + return uvExecutable; + }); + + const first = manager.create(scriptUri('a.py')); + await promptShown; + const second = manager.create(scriptUri('b.py')); + await secondInitialQuery; + await Promise.resolve(); + releaseInstall!(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.ok(firstResult); + assert.strictEqual(firstResult, secondResult); + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 1); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('reuses a compatible installation for a queued request with a different lower bound', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.1', uvExecutable); + readMetadataStub.callsFake(async (uri: Uri) => ({ + ...VALID_METADATA, + requiresPython: uri.fsPath.endsWith('compatible.py') ? '~=3.13.0' : '>=3.13', + })); + + let installed = false; + let queryCount = 0; + let signalQueuedQuery: (() => void) | undefined; + const queuedQuery = new Promise((resolve) => { + signalQueuedQuery = resolve; + }); + apiGetEnvironmentsStub.callsFake(async () => { + queryCount += 1; + if (queryCount === 3) { + signalQueuedQuery!(); + } + return installed ? [uvBase] : []; + }); + + let releaseInstall: (() => void) | undefined; + let signalPrompt: (() => void) | undefined; + const promptShown = new Promise((resolve) => { + signalPrompt = resolve; + }); + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + promptInstallPythonViaUvStub.callsFake(async () => { + signalPrompt!(); + await installGate; + installed = true; + return uvExecutable; + }); + + const first = manager.create(scriptUri('lower-bound.py')); + await promptShown; + const second = manager.create(scriptUri('compatible.py')); + await queuedQuery; + releaseInstall!(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.ok(firstResult); + assert.strictEqual(firstResult, secondResult); + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 1); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('reuses a directly resolved installation when discovery remains stale', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.resolves([baseEnvironment]); + resolveSystemPythonStub.resolves(uvBase); + + let releaseInstall: (() => void) | undefined; + let signalPrompt: (() => void) | undefined; + const promptShown = new Promise((resolve) => { + signalPrompt = resolve; + }); + const installGate = new Promise((resolve) => { + releaseInstall = resolve; + }); + promptInstallPythonViaUvStub.callsFake(async () => { + signalPrompt!(); + await installGate; + return uvExecutable; + }); + + const first = manager.create(scriptUri('first.py')); + await promptShown; + const second = manager.create(scriptUri('second.py')); + releaseInstall!(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.ok(firstResult); + assert.strictEqual(firstResult, secondResult); + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 1); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 1); + assert.strictEqual(resolveSystemPythonStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('reuses a directly resolved installation when later discovery throws', async () => { + const uvExecutable = path.join(tempRoot, 'uv-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(uvExecutable, ''); + const uvBase = makeEnvironment('ms-python.python:system', '3.13.1', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onCall(3).rejects(new Error('discovery unavailable')); + resolveSystemPythonStub.resolves(uvBase); + promptInstallPythonViaUvStub.resolves(uvExecutable); + + assert.ok(await manager.create(scriptUri('first.py'))); + assert.ok(await manager.create(scriptUri('second.py'))); + + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 1); + assert.strictEqual(resolveSystemPythonStub.callCount, 1); + }); + }); + suite('cache creation', () => { test('hashes and installs metadata plus additional packages, then writes the sidecar', async () => { const result = await manager.create(scriptUri(), { additionalPackages: ['pytest'] }); diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 4190d33c..5e24b61c 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -11,6 +11,7 @@ import * as windowApis from '../../../common/window.apis'; import * as helpers from '../../../managers/builtin/helpers'; import { clearDontAskAgain, + ensureUvForInlineScriptVersionLookup, getAvailablePythonVersions, getUvPythonPath, isDontAskAgainSet, @@ -192,6 +193,32 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { assert(isUvInstalledStub.notCalled, 'Should stop before checking or installing uv'); }); + test('should allow a validated prerelease install version', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.15.0a1', + version: '3.15.0a1', + }); + + assert(showInformationMessageStub.calledOnce, 'Should offer the requested prerelease'); + }); + + test('should request consent before installing uv for version lookup', async () => { + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(undefined); + + assert.strictEqual(await ensureUvForInlineScriptVersionLookup('>=3.13,<3.14', mockLog), false); + sinon.assert.calledOnceWithExactly( + showInformationMessageStub, + UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt('>=3.13,<3.14'), + { modal: true }, + UvInstallStrings.installUv, + ); + }); + test('should trim inline-script context before displaying it', async () => { mockState.get.resolves(false); isUvInstalledStub.resolves(true); From 21fb1d261403abf6cb6bdefa52ca29cab406fb37 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Fri, 7 Aug 2026 11:11:52 -0700 Subject: [PATCH 2/2] address feedback --- src/managers/builtin/inlineScript/envManager.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 717da084..5bba9693 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -229,13 +229,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }), ); let candidates = derivedChecks - .filter((candidate) => !candidate.derived) - .map((candidate) => candidate.environment) .filter( (candidate) => - !metadata.requiresPython || - this.matchesInstallConstraint(metadata.requiresPython, candidate.version), - ); + !candidate.derived && + (!metadata.requiresPython || + this.matchesInstallConstraint(metadata.requiresPython, candidate.environment.version)), + ) + .map((candidate) => candidate.environment); while (candidates.length > 0) { const environment = pickCompatibleInterpreter(candidates, undefined); @@ -266,6 +266,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const run = this.baseInterpreterInstallationQueue.then(() => this.installAndSelectBaseInterpreterSerially(metadata), ); + // Keep the stored queue tail fulfilled so one failed request does not block later attempts; + // the caller still observes the original result through `run`. this.baseInterpreterInstallationQueue = run.then( () => undefined, () => undefined,