diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 34a847c3..04505bba 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -2363,16 +2363,19 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.baseManager, ); const executable = resolved?.execInfo?.run.executable; - if (resolved && executable && pickCompatibleInterpreter([resolved], metadata.requiresPython)) { + if ( + resolved && + executable && + pickCompatibleInterpreter([resolved], undefined) && + (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) + ) { try { const canonicalPath = await fs.realpath(executable); - if (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) { - this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved); - selected = { - environment: resolved, - canonicalPath, - }; - } + 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)}`, @@ -2403,12 +2406,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { version: prereleaseLowerBound }; } const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined; + let needsCompleteCatalog = false; if (lowerBound && lowerBoundRelease?.[0] === 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)) { - return { version: lowerBound }; + if (lowerBoundRelease.length >= 3) { + return { version: lowerBound }; + } + needsCompleteCatalog = true; } } @@ -2424,7 +2432,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { uvLookupResult === 'declined' ? 'compatible-python-declined' : 'install-failure', }; } - available = await uvPythonInstaller.getAvailablePythonVersions(); + 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' }; diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 7fabe4b5..e57991dd 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -76,6 +76,10 @@ export interface UvPythonVersion { arch: string; } +export interface GetAvailablePythonVersionsOptions { + readonly allVersions?: boolean; +} + /** * Checks if a command is available on the system. */ @@ -276,12 +280,20 @@ export async function getUvPythonPath(version?: string): Promise { +export async function getAvailablePythonVersions( + options?: GetAvailablePythonVersionsOptions, +): Promise { return new Promise((resolve) => { const chunks: string[] = []; - const proc = spawnProcess('uv', ['python', 'list', '--output-format', 'json']); + const args = ['python', 'list']; + if (options?.allVersions) { + args.push('--all-versions'); + } + args.push('--output-format', 'json'); + const proc = spawnProcess('uv', args); proc.stdout?.on('data', (data) => chunks.push(data.toString())); proc.on('error', () => resolve([])); proc.on('exit', (code) => { diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index c95bfc78..0d19a108 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -744,6 +744,25 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(createWithProgressStub.callCount, 1); }); + test('uses strict PEP 440 matching for a directly resolved final release', 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.0', uvExecutable); + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '!=3.15.0rc2' }); + apiGetEnvironmentsStub.resolves([]); + getAvailablePythonVersionsStub.resolves([makeUvPythonVersion('3.15.0')]); + promptInstallPythonViaUvStub.resolves({ kind: 'installed', pythonPath: uvExecutable }); + resolveSystemPythonStub.resolves(uvBase); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '!=3.15.0rc2', + version: '3.15.0', + }); + assert.strictEqual(createWithProgressStub.firstCall.args[4], uvBase); + }); + 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, ''); @@ -834,6 +853,46 @@ suite('InlineScriptEnvManager', () => { requiresPython: '>=3.11,<3.12', version: '3.11.14', }); + sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub); + }); + + test('resolves a short exact requirement to an advertised concrete release', 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' }); + apiGetEnvironmentsStub.onFirstCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onSecondCall().resolves([baseEnvironment]); + apiGetEnvironmentsStub.onThirdCall().resolves([uvBase]); + const defaultCatalog = [makeUvPythonVersion('3.13.2')]; + const completeCatalog = [...defaultCatalog, makeUvPythonVersion('3.13.0')]; + getAvailablePythonVersionsStub.callsFake(async (options?: { allVersions?: boolean }) => + options?.allVersions ? completeCatalog : defaultCatalog, + ); + promptInstallPythonViaUvStub.resolves({ kind: 'installed', pythonPath: uvExecutable }); + + assert.ok(await manager.create(scriptUri())); + + sinon.assert.calledOnceWithExactly(ensureUvForVersionLookupStub, '==3.13', manager.log); + sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub, { allVersions: true }); + sinon.assert.calledOnceWithExactly(promptInstallPythonViaUvStub, 'inlineScript', manager.log, { + requiresPython: '==3.13', + version: '3.13.0', + }); + }); + + test('does not install a short exact requirement without an exact catalog candidate', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '==3.13' }); + apiGetEnvironmentsStub.resolves([baseEnvironment]); + getAvailablePythonVersionsStub.resolves([makeUvPythonVersion('3.13.2')]); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + + sinon.assert.calledOnceWithExactly(ensureUvForVersionLookupStub, '==3.13', manager.log); + sinon.assert.calledOnceWithExactly(getAvailablePythonVersionsStub, { allVersions: true }); + assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); + assert.strictEqual(apiRefreshEnvironmentsStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); }); test('uses an exact requirement without needing an existing uv catalog', async () => { diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index b2d55658..1c1f2dd5 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -835,6 +835,32 @@ suite('uvPythonInstaller - getAvailablePythonVersions', () => { assert.strictEqual(result.length, 2, 'Should return all versions'); assert.strictEqual(result[0].version, '3.13.1'); assert.strictEqual(result[1].version, '3.12.8'); + sinon.assert.calledOnceWithExactly(spawnStub, 'uv', ['python', 'list', '--output-format', 'json']); + }); + + test('should request older patch releases only when all versions are requested', async () => { + const versions: UvPythonVersion[] = [ + makeUvPythonVersion({ version: '3.13.2', path: null }), + makeUvPythonVersion({ version: '3.13.0', path: null }), + ]; + const args = ['python', 'list', '--all-versions', '--output-format', 'json']; + const mockProcess = new MockChildProcess('uv', args); + spawnStub.returns(mockProcess); + + const resultPromise = getAvailablePythonVersions({ allVersions: true }); + + setTimeout(() => { + mockProcess.stdout?.emit('data', JSON.stringify(versions)); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + assert.deepStrictEqual( + result.map((version) => version.version), + ['3.13.2', '3.13.0'], + ); + sinon.assert.calledOnceWithExactly(spawnStub, 'uv', args); }); test('should return empty array on process error', async () => {