diff --git a/src/common/utils/pep440Release.ts b/src/common/utils/pep440Release.ts index 3021d2dcc..8f0f59f6f 100644 --- a/src/common/utils/pep440Release.ts +++ b/src/common/utils/pep440Release.ts @@ -3,16 +3,50 @@ import { clean as cleanPep440Version, explain as explainPep440Version } from '@renovatebot/pep440'; +/** + * Convert a CPython `sys.version_info`-style version string to PEP 440. + * + * The native locator (`pet`) reports interpreter versions as + * `major.minor.micro.releaselevel.serial` — for example `"3.14.3.final.0"` or + * `"3.14.0.candidate.2"`. That shape is **not** valid PEP 440, so the + * `@renovatebot/pep440` helpers (`clean`, `satisfies`, …) reject it. This maps + * it to the PEP 440 equivalent (`"3.14.3"`, `"3.14.0rc2"`). + * + * The numeric release segments are preserved verbatim (no zero-padding, so + * `"3.14"` is never rewritten to `"3.14.0"`), and any string that does not + * match the `sys.version_info` shape is returned unchanged. + */ +export function normalizeCpythonVersionInfo(version: string): string { + const match = /^(\d+(?:\.\d+)*)\.(alpha|beta|candidate|final)\.(\d+)$/i.exec(version.trim()); + if (!match) { + return version; + } + const [, release, level, serial] = match; + switch (level.toLowerCase()) { + case 'alpha': + return `${release}a${serial}`; + case 'beta': + return `${release}b${serial}`; + case 'candidate': + return `${release}rc${serial}`; + case 'final': + default: + return release; + } +} + /** * Parse the release segments from a PEP 440 version string. * * Release segments are the dotted numeric components of a version, such as * `[3, 12, 4]` for `3.12.4`. Leading/trailing whitespace, a leading `v`, and * an epoch prefix are ignored. Pre-release, post-release, development, and - * local-version suffixes are intentionally omitted. + * local-version suffixes are intentionally omitted. CPython `sys.version_info` + * strings (e.g. `"3.14.3.final.0"`) are normalized via + * {@link normalizeCpythonVersionInfo} before parsing. */ export function parseReleaseSegments(version: string): number[] | undefined { - const normalized = cleanPep440Version(version); + const normalized = cleanPep440Version(normalizeCpythonVersionInfo(version)); return normalized ? (explainPep440Version(normalized)?.release ?? undefined) : undefined; } diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d1a4d25b9..6b4e10a57 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -69,7 +69,11 @@ import { sendTelemetryEvent } from '../../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../../common/utils/deferred'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; -import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; +import { + compareReleaseSegments, + normalizeCpythonVersionInfo, + parseReleaseSegments, +} from '../../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis'; import { NativePythonFinder } from '../../common/nativePythonFinder'; @@ -2503,7 +2507,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private matchesInstallConstraint(requiresPython: string, version: string): boolean { try { - return satisfiesPep440(version, requiresPython, { + return satisfiesPep440(normalizeCpythonVersionInfo(version), requiresPython, { prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test( requiresPython, ), diff --git a/src/test/common/utils/pep440Release.unit.test.ts b/src/test/common/utils/pep440Release.unit.test.ts index 9f4a74c30..2c09e655f 100644 --- a/src/test/common/utils/pep440Release.unit.test.ts +++ b/src/test/common/utils/pep440Release.unit.test.ts @@ -2,14 +2,55 @@ // Licensed under the MIT License. import assert from 'assert'; -import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; +import { + compareReleaseSegments, + normalizeCpythonVersionInfo, + parseReleaseSegments, +} from '../../../common/utils/pep440Release'; suite('pep440Release', () => { + suite('normalizeCpythonVersionInfo', () => { + test('rewrites a final sys.version_info string to its release', () => { + assert.strictEqual(normalizeCpythonVersionInfo('3.14.3.final.0'), '3.14.3'); + }); + + test('rewrites prerelease sys.version_info strings to PEP 440 prereleases', () => { + assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.alpha.1'), '3.14.0a1'); + assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.beta.2'), '3.14.0b2'); + assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.candidate.3'), '3.14.0rc3'); + }); + + test('trims surrounding whitespace before matching', () => { + assert.strictEqual(normalizeCpythonVersionInfo(' 3.14.3.final.0 '), '3.14.3'); + }); + + test('does not zero-pad the release segments', () => { + assert.strictEqual(normalizeCpythonVersionInfo('3.14.final.0'), '3.14'); + }); + + test('returns non-version_info strings unchanged', () => { + assert.strictEqual(normalizeCpythonVersionInfo('3.14.3'), '3.14.3'); + assert.strictEqual(normalizeCpythonVersionInfo('3.13'), '3.13'); + assert.strictEqual(normalizeCpythonVersionInfo('3.14.0rc2'), '3.14.0rc2'); + assert.strictEqual(normalizeCpythonVersionInfo('>=3.11'), '>=3.11'); + assert.strictEqual(normalizeCpythonVersionInfo('3.12.not-a-version'), '3.12.not-a-version'); + }); + }); + suite('parseReleaseSegments', () => { test('parses dotted numeric release segments', () => { assert.deepStrictEqual(parseReleaseSegments('3.12.4'), [3, 12, 4]); }); + test('parses CPython sys.version_info release strings', () => { + assert.deepStrictEqual(parseReleaseSegments('3.14.3.final.0'), [3, 14, 3]); + assert.deepStrictEqual(parseReleaseSegments('3.14.0.candidate.2'), [3, 14, 0]); + }); + + test('does not zero-pad release segments (keeps uv install targets intact)', () => { + assert.deepStrictEqual(parseReleaseSegments('3.13'), [3, 13]); + }); + test('ignores syntax outside the release segments', () => { assert.deepStrictEqual(parseReleaseSegments(' v2!3.12.4rc1.post2.dev3+local '), [3, 12, 4]); }); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 5bd7a6ecf..88b387c91 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -553,6 +553,29 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0); }); + test('creates an environment when the resolved base reports a CPython sys.version_info version', async () => { + // `pet` reports interpreter versions as `major.minor.micro.releaselevel.serial` + // (e.g. "3.14.3.final.0"), which is not valid PEP 440. This must still satisfy + // requires-python (matchesInstallConstraint) and pass the post-create + // release-equality check (areEqualPythonReleases) instead of being discarded. + const versionInfoBase = makeEnvironment('ms-python.python:system', '3.14.3.final.0', baseExecutable); + apiGetEnvironmentsStub.resolves([versionInfoBase]); + + assert.ok(await manager.create(scriptUri())); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], versionInfoBase); + }); + + test('creates an environment with a sys.version_info version when requires-python is absent', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: undefined }); + const versionInfoBase = makeEnvironment('ms-python.python:system', '3.14.3.final.0', baseExecutable); + apiGetEnvironmentsStub.resolves([versionInfoBase]); + + assert.ok(await manager.create(scriptUri())); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], versionInfoBase); + }); + test('excludes named conda environments even when they are newer than conda base', async () => { const condaNamed = makeEnvironment( 'ms-python.python:conda',