Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/common/utils/pep440Release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
8 changes: 6 additions & 2 deletions src/managers/builtin/inlineScript/envManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
),
Expand Down
43 changes: 42 additions & 1 deletion src/test/common/utils/pep440Release.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
Expand Down
23 changes: 23 additions & 0 deletions src/test/managers/builtin/inlineScript/envManager.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down