Skip to content

Commit c5520ef

Browse files
Fix inline-script env creation for CPython sys.version_info versions (PEP 723) (#1752)
## Summary Fixes PEP 723 inline-script environment **creation**, which currently fails for every interpreter whose version the native locator (`pet`) reports in CPython's `sys.version_info` form (e.g. `3.14.3.final.0`). The venv is provisioned correctly (base interpreter selected, `uv venv --seed` + dependencies installed), but is then **rejected by a post-creation self-check and deleted**, surfacing: > Created inline-script environment does not match the requested cache entry. ## Root cause `pet resolve` returns interpreter versions as `major.minor.micro.releaselevel.serial` — e.g. `"3.14.3.final.0"` (verified against the bundled `pet` in **both server mode and CLI**, for Python 3.12 / 3.13 / 3.14). That string is **not valid PEP 440**, and the `@renovatebot/pep440` helpers reject it: - `parseReleaseSegments("3.14.3.final.0")` → `undefined` (because `clean()` returns `null`). `areEqualPythonReleases()` guards on `undefined` and returns **`false` even when both versions are identical**, so `buildCacheEntry()` treats the freshly-created environment as a version mismatch and deletes it. This blocks creation for scripts **without** `requires-python`. - `satisfies("3.14.3.final.0", ">=3.11")` **throws** (`Cannot destructure property 'epoch' of 'input' as it is null`). `matchesInstallConstraint()` catches it and returns `false`, so scripts **with** `requires-python` find no compatible base interpreter during `selectBaseInterpreter()`. The repo's existing `PythonVersion` class already parses the `sys.version_info` form (so `matchesPythonVersion` was unaffected); only the two `@renovatebot/pep440`-based helpers were not. ## Fix Add `normalizeCpythonVersionInfo()` in `common/utils/pep440Release.ts`, converting the `sys.version_info` form to PEP 440: - `3.14.3.final.0` → `3.14.3` - `3.14.0.candidate.2` → `3.14.0rc2` (and `.alpha.N` / `.beta.N` → `aN` / `bN`) - release segments are preserved verbatim — **no zero-padding**, so `3.13` stays `3.13` and `extractLowerBoundVersion()`'s `uv python install` targets are unchanged - any non-`sys.version_info` string is returned unchanged Apply it in: - `parseReleaseSegments()` → fixes `areEqualPythonReleases()` (create + reuse cache validation) and the interpreter/candidate version sorting that also depends on it - `matchesInstallConstraint()` (before `satisfiesPep440`) → fixes the `requires-python` base-selection and reuse paths Scope is limited to the inline-script feature: `parseReleaseSegments` is used only by inline-script code, and `matchesInstallConstraint` is private to the inline-script manager. No change to venv / system / conda / other environment resolution. ## Testing - New unit tests for `normalizeCpythonVersionInfo` and `parseReleaseSegments` (final + prerelease forms, whitespace, no zero-padding, non-matching passthrough). - New create-flow regression tests in the inline-script manager suite: creation succeeds when the resolved base reports `3.14.3.final.0`, both **with** and **without** `requires-python` (both previously returned `undefined`). - Full unit suite: **1978 passing / 6 pending**. `tsc` (`npm run compile-tests`) and `eslint` clean. ## Impact / benefit Restores end-to-end PEP 723 inline-script environment creation on setups where the bundled `pet` reports `sys.version_info`-style versions (observed with `pet 0.1.0-dev.428887` on Windows for Python 3.12–3.14). Without this fix the "Set up environment" / CodeLens flow provisions the venv and then silently discards it, so users cannot create an inline-script environment at all. ## Out of scope The same non-PEP-440 version string also makes `shortenVersionString()` return `"3.14.3.final.0"` verbatim (a **cosmetic** display artifact that affects all pet-resolved environments, not just inline scripts). That is a separate, non-blocking issue and is intentionally left for a follow-up to keep this fix's blast radius minimal.
1 parent 3570eeb commit c5520ef

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)