Skip to content

Commit 7101a06

Browse files
Fix inline-script env creation for CPython sys.version_info versions (PEP 723)
The native locator (pet) reports interpreter versions in CPython's `sys.version_info` form, e.g. "3.14.3.final.0", which is not valid PEP 440. The `@renovatebot/pep440` helpers reject that shape, so the inline-script manager mishandled every such interpreter: - `areEqualPythonReleases()` returned false even for two identical versions, because `parseReleaseSegments("3.14.3.final.0")` yields `undefined`. A freshly created environment was therefore judged not to match its own requested version, rejected with "Created inline-script environment does not match the requested cache entry", and deleted — so env creation failed for scripts without `requires-python`. - `matchesInstallConstraint()` threw (caught, returning false), so scripts with `requires-python` found no compatible base interpreter. Add `normalizeCpythonVersionInfo()`, which converts the `sys.version_info` form to PEP 440 ("3.14.3.final.0" -> "3.14.3", "3.14.0.candidate.2" -> "3.14.0rc2"). It preserves the release segments verbatim (no zero-padding, so `extractLowerBoundVersion`'s `uv python install` targets are unaffected) and returns any other string unchanged. Apply it inside `parseReleaseSegments()` (fixing `areEqualPythonReleases` and the interpreter/candidate sorting that also depend on it) and before `satisfiesPep440()` in `matchesInstallConstraint()` (fixing the `requires-python` paths). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3570eeb commit 7101a06

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

src/common/utils/pep440Release.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,50 @@
33

44
import { clean as cleanPep440Version, explain as explainPep440Version } from '@renovatebot/pep440';
55

6+
/**
7+
* Convert a CPython `sys.version_info`-style version string to PEP 440.
8+
*
9+
* The native locator (`pet`) reports interpreter versions as
10+
* `major.minor.micro.releaselevel.serial` — for example `"3.14.3.final.0"` or
11+
* `"3.14.0.candidate.2"`. That shape is **not** valid PEP 440, so the
12+
* `@renovatebot/pep440` helpers (`clean`, `satisfies`, …) reject it. This maps
13+
* it to the PEP 440 equivalent (`"3.14.3"`, `"3.14.0rc2"`).
14+
*
15+
* The numeric release segments are preserved verbatim (no zero-padding, so
16+
* `"3.14"` is never rewritten to `"3.14.0"`), and any string that does not
17+
* match the `sys.version_info` shape is returned unchanged.
18+
*/
19+
export function normalizeCpythonVersionInfo(version: string): string {
20+
const match = /^(\d+(?:\.\d+)*)\.(alpha|beta|candidate|final)\.(\d+)$/i.exec(version.trim());
21+
if (!match) {
22+
return version;
23+
}
24+
const [, release, level, serial] = match;
25+
switch (level.toLowerCase()) {
26+
case 'alpha':
27+
return `${release}a${serial}`;
28+
case 'beta':
29+
return `${release}b${serial}`;
30+
case 'candidate':
31+
return `${release}rc${serial}`;
32+
case 'final':
33+
default:
34+
return release;
35+
}
36+
}
37+
638
/**
739
* Parse the release segments from a PEP 440 version string.
840
*
941
* Release segments are the dotted numeric components of a version, such as
1042
* `[3, 12, 4]` for `3.12.4`. Leading/trailing whitespace, a leading `v`, and
1143
* an epoch prefix are ignored. Pre-release, post-release, development, and
12-
* local-version suffixes are intentionally omitted.
44+
* local-version suffixes are intentionally omitted. CPython `sys.version_info`
45+
* strings (e.g. `"3.14.3.final.0"`) are normalized via
46+
* {@link normalizeCpythonVersionInfo} before parsing.
1347
*/
1448
export function parseReleaseSegments(version: string): number[] | undefined {
15-
const normalized = cleanPep440Version(version);
49+
const normalized = cleanPep440Version(normalizeCpythonVersionInfo(version));
1650
return normalized ? (explainPep440Version(normalized)?.release ?? undefined) : undefined;
1751
}
1852

src/managers/builtin/inlineScript/envManager.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ import { sendTelemetryEvent } from '../../../common/telemetry/sender';
6969
import { createDeferred, Deferred } from '../../../common/utils/deferred';
7070
import { isFileNotFoundError } from '../../../common/utils/filesystem';
7171
import { normalizePath } from '../../../common/utils/pathUtils';
72-
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
72+
import {
73+
compareReleaseSegments,
74+
normalizeCpythonVersionInfo,
75+
parseReleaseSegments,
76+
} from '../../../common/utils/pep440Release';
7377
import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment';
7478
import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis';
7579
import { NativePythonFinder } from '../../common/nativePythonFinder';
@@ -2503,7 +2507,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
25032507

25042508
private matchesInstallConstraint(requiresPython: string, version: string): boolean {
25052509
try {
2506-
return satisfiesPep440(version, requiresPython, {
2510+
return satisfiesPep440(normalizeCpythonVersionInfo(version), requiresPython, {
25072511
prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test(
25082512
requiresPython,
25092513
),

src/test/common/utils/pep440Release.unit.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,55 @@
22
// Licensed under the MIT License.
33

44
import assert from 'assert';
5-
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
5+
import {
6+
compareReleaseSegments,
7+
normalizeCpythonVersionInfo,
8+
parseReleaseSegments,
9+
} from '../../../common/utils/pep440Release';
610

711
suite('pep440Release', () => {
12+
suite('normalizeCpythonVersionInfo', () => {
13+
test('rewrites a final sys.version_info string to its release', () => {
14+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.3.final.0'), '3.14.3');
15+
});
16+
17+
test('rewrites prerelease sys.version_info strings to PEP 440 prereleases', () => {
18+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.alpha.1'), '3.14.0a1');
19+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.beta.2'), '3.14.0b2');
20+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.candidate.3'), '3.14.0rc3');
21+
});
22+
23+
test('trims surrounding whitespace before matching', () => {
24+
assert.strictEqual(normalizeCpythonVersionInfo(' 3.14.3.final.0 '), '3.14.3');
25+
});
26+
27+
test('does not zero-pad the release segments', () => {
28+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.final.0'), '3.14');
29+
});
30+
31+
test('returns non-version_info strings unchanged', () => {
32+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.3'), '3.14.3');
33+
assert.strictEqual(normalizeCpythonVersionInfo('3.13'), '3.13');
34+
assert.strictEqual(normalizeCpythonVersionInfo('3.14.0rc2'), '3.14.0rc2');
35+
assert.strictEqual(normalizeCpythonVersionInfo('>=3.11'), '>=3.11');
36+
assert.strictEqual(normalizeCpythonVersionInfo('3.12.not-a-version'), '3.12.not-a-version');
37+
});
38+
});
39+
840
suite('parseReleaseSegments', () => {
941
test('parses dotted numeric release segments', () => {
1042
assert.deepStrictEqual(parseReleaseSegments('3.12.4'), [3, 12, 4]);
1143
});
1244

45+
test('parses CPython sys.version_info release strings', () => {
46+
assert.deepStrictEqual(parseReleaseSegments('3.14.3.final.0'), [3, 14, 3]);
47+
assert.deepStrictEqual(parseReleaseSegments('3.14.0.candidate.2'), [3, 14, 0]);
48+
});
49+
50+
test('does not zero-pad release segments (keeps uv install targets intact)', () => {
51+
assert.deepStrictEqual(parseReleaseSegments('3.13'), [3, 13]);
52+
});
53+
1354
test('ignores syntax outside the release segments', () => {
1455
assert.deepStrictEqual(parseReleaseSegments(' v2!3.12.4rc1.post2.dev3+local '), [3, 12, 4]);
1556
});

src/test/managers/builtin/inlineScript/envManager.unit.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,29 @@ suite('InlineScriptEnvManager', () => {
553553
assert.strictEqual(promptInstallPythonViaUvStub.callCount, 0);
554554
});
555555

556+
test('creates an environment when the resolved base reports a CPython sys.version_info version', async () => {
557+
// `pet` reports interpreter versions as `major.minor.micro.releaselevel.serial`
558+
// (e.g. "3.14.3.final.0"), which is not valid PEP 440. This must still satisfy
559+
// requires-python (matchesInstallConstraint) and pass the post-create
560+
// release-equality check (areEqualPythonReleases) instead of being discarded.
561+
const versionInfoBase = makeEnvironment('ms-python.python:system', '3.14.3.final.0', baseExecutable);
562+
apiGetEnvironmentsStub.resolves([versionInfoBase]);
563+
564+
assert.ok(await manager.create(scriptUri()));
565+
566+
assert.strictEqual(createWithProgressStub.firstCall.args[4], versionInfoBase);
567+
});
568+
569+
test('creates an environment with a sys.version_info version when requires-python is absent', async () => {
570+
readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: undefined });
571+
const versionInfoBase = makeEnvironment('ms-python.python:system', '3.14.3.final.0', baseExecutable);
572+
apiGetEnvironmentsStub.resolves([versionInfoBase]);
573+
574+
assert.ok(await manager.create(scriptUri()));
575+
576+
assert.strictEqual(createWithProgressStub.firstCall.args[4], versionInfoBase);
577+
});
578+
556579
test('excludes named conda environments even when they are newer than conda base', async () => {
557580
const condaNamed = makeEnvironment(
558581
'ms-python.python:conda',

0 commit comments

Comments
 (0)