Skip to content

Commit 91ea3fc

Browse files
feat(inline-scripts): add interpreter selection helpers (PEP 723 PR 3/3)
Pure helpers that pick the right installed Python interpreter for a PEP 723 script (given its requires-python specifier) and extract the lower-bound version to hand to `uv python install` when no compatible interpreter is installed. Third foundation PR -- pure utility, no behavior change on its own. What is in: - src/common/inlineScriptInterpreter.ts - pickCompatibleInterpreter(installed, requiresPython) -- filters out errored envs, version-unparseable envs, Python 2 (and Python 4+ until the code is revisited), and when constrained, anything that does not satisfy requires-python. Sorts by version descending and returns the head. Stable sort preserves input order for ties so callers can express a preference (e.g. "system Pythons first, then uv-managed"). Empty-string requiresPython is normalized to "no constraint" rather than "matches nothing". Docstring spells out the caller contract: only base interpreters (system, pyenv, uv, conda base), not derived envs. - extractLowerBoundVersion(requiresPython) -- extracts the floor version string suitable for `uv python install <version>`: ">=3.13" -> "3.13" ">=3.11,<3.13" -> "3.11" (tightest lower bound) "==3.12.*" -> "3.12" "~=3.12.4" -> "3.12.4" Returns undefined (and the caller falls back to uv defaults) for upper-bound-only specs, the `>` operator (no clean integer floor), `===` (opaque shape), illegal `~=X` without a minor segment, illegal wildcards on `>=` / `~=`, and unparseable input. Every rejection mirrors matchesPythonVersion so a value we hand to uv is one the picker will then accept post-install. - src/test/common/inlineScriptInterpreter.unit.test.ts -- 34 unit tests covering: empty input, multi-clause specs, errored envs, Python-2 filtering, ties (stable sort), wildcard specs (==X.* in picker), implicit upper bound of `~=X.Y.Z`, empty-string constraint, pre/dev/local-suffix version ranking, lower-bound extraction across all operators, mixed-clause cases, illegal shape rejection (`~=3`, `>=3.*`, `~=3.12.*`). Re-uses matchesPythonVersion and the lessons of PEP 440 shape validation from inlineScriptMetadata.ts rather than re-implementing the matcher. Design context Implements the "pick a compatible interpreter" half of Q4 step 1 from pep723_design_questions.md, plus the lower-bound extraction needed to wire up step 1`s fallback to promptInstallPythonViaUv in a later PR. The actual call sites (creation flow, fallback flow, re-verify after install) all live in a later PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5d919c1 commit 91ea3fc

2 files changed

Lines changed: 476 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { PythonEnvironment } from '../api';
5+
import { matchesPythonVersion } from './inlineScriptMetadata';
6+
import { traceWarn } from './logging';
7+
8+
/**
9+
* Pick the newest installed Python that can serve as a base interpreter for
10+
* a PEP 723 script. Returns `undefined` if no candidate is usable (the
11+
* caller is then expected to prompt for a uv install or surface an error).
12+
*
13+
* **Caller contract**: `installed` must contain only BASE interpreters
14+
* (system Pythons, pyenv-installed, uv-installed, conda `base`) — never
15+
* venvs / conda named envs / poetry / pipenv project envs. This function
16+
* does not filter derived envs out, and using one as a venv base produces
17+
* a nested or broken environment. `api.getEnvironments('global')` is the
18+
* right source (with the caveat that pipenv's `'global'` scope is known
19+
* to leak derived envs).
20+
*/
21+
export function pickCompatibleInterpreter(
22+
installed: ReadonlyArray<PythonEnvironment>,
23+
requiresPython: string | undefined,
24+
): PythonEnvironment | undefined {
25+
const constraint = requiresPython && requiresPython.length > 0 ? requiresPython : undefined;
26+
const candidates = installed.filter((env) => isUsableBaseInterpreter(env, constraint));
27+
if (candidates.length === 0) {
28+
return undefined;
29+
}
30+
const sorted = [...candidates].sort((a, b) => compareVersionsDescending(a.version, b.version));
31+
return sorted[0];
32+
}
33+
34+
/**
35+
* Extract a lower-bound version string from a PEP 440 `requires-python`
36+
* specifier, suitable as the `version` argument to `uv python install`.
37+
*
38+
* Examples:
39+
* ">=3.13" → "3.13"
40+
* ">=3.11,<3.13" → "3.11" (tightest lower bound across clauses)
41+
* "~=3.12.4" → "3.12.4"
42+
* "==3.12.*" → "3.12"
43+
* "==3.12.7" → "3.12.7"
44+
*
45+
* Returns `undefined` for specifiers without a clean lower bound (`<3.13`,
46+
* `!=3.10`, `>3.12`, `===…`, illegal shapes like `~=3` or `>=3.*`). The
47+
* caller falls back to the uv default and re-verifies with
48+
* `matchesPythonVersion` after install.
49+
*/
50+
export function extractLowerBoundVersion(requiresPython: string | undefined): string | undefined {
51+
if (!requiresPython) {
52+
return undefined;
53+
}
54+
const clauses = requiresPython
55+
.split(',')
56+
.map((c) => c.trim())
57+
.filter((c) => c.length > 0);
58+
if (clauses.length === 0) {
59+
return undefined;
60+
}
61+
62+
let best: number[] | undefined;
63+
let bestStr: string | undefined;
64+
for (const clause of clauses) {
65+
const lb = lowerBoundForClause(clause);
66+
if (lb === undefined) {
67+
continue;
68+
}
69+
if (best === undefined || compareReleaseSegments(lb.segments, best) > 0) {
70+
best = lb.segments;
71+
bestStr = lb.display;
72+
}
73+
}
74+
return bestStr;
75+
}
76+
77+
function isUsableBaseInterpreter(env: PythonEnvironment, requiresPython: string | undefined): boolean {
78+
if (env.error) {
79+
return false;
80+
}
81+
if (typeof env.version !== 'string' || env.version.length === 0) {
82+
return false;
83+
}
84+
if (parseLeadingMajor(env.version) !== 3) {
85+
return false;
86+
}
87+
if (requiresPython !== undefined && !matchesPythonVersion(requiresPython, env.version)) {
88+
return false;
89+
}
90+
return true;
91+
}
92+
93+
function parseLeadingMajor(version: string): number | undefined {
94+
const m = version.match(/^\s*v?(\d+)/i);
95+
if (!m) {
96+
return undefined;
97+
}
98+
const n = Number.parseInt(m[1], 10);
99+
return Number.isNaN(n) ? undefined : n;
100+
}
101+
102+
function parseReleaseSegments(version: string): number[] | undefined {
103+
const m = version.match(/^v?(\d+(?:\.\d+)*)/i);
104+
if (!m) {
105+
return undefined;
106+
}
107+
return m[1].split('.').map((s) => Number.parseInt(s, 10));
108+
}
109+
110+
function compareReleaseSegments(a: ReadonlyArray<number>, b: ReadonlyArray<number>): number {
111+
const n = Math.max(a.length, b.length);
112+
for (let i = 0; i < n; i++) {
113+
const av = a[i] ?? 0;
114+
const bv = b[i] ?? 0;
115+
if (av < bv) {
116+
return -1;
117+
}
118+
if (av > bv) {
119+
return 1;
120+
}
121+
}
122+
return 0;
123+
}
124+
125+
function compareVersionsDescending(a: string, b: string): number {
126+
const aSeg = parseReleaseSegments(a);
127+
const bSeg = parseReleaseSegments(b);
128+
if (aSeg === undefined && bSeg === undefined) {
129+
return 0;
130+
}
131+
if (aSeg === undefined) {
132+
return 1;
133+
}
134+
if (bSeg === undefined) {
135+
return -1;
136+
}
137+
return compareReleaseSegments(bSeg, aSeg);
138+
}
139+
140+
const CLAUSE_RE = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/;
141+
142+
interface LowerBound {
143+
readonly segments: number[];
144+
readonly display: string;
145+
}
146+
147+
function lowerBoundForClause(clause: string): LowerBound | undefined {
148+
const m = clause.match(CLAUSE_RE);
149+
if (!m) {
150+
traceWarn(`inline-script interpreter: unrecognized requires-python clause: ${JSON.stringify(clause)}`);
151+
return undefined;
152+
}
153+
const op = m[1];
154+
const raw = m[2].trim();
155+
156+
switch (op) {
157+
case '>=': {
158+
// Per PEP 440 wildcards are only legal with `==` / `!=`. Stay
159+
// consistent with matchesPythonVersion (which rejects `>=X.*`)
160+
// so we never hand uv a value the picker will then reject.
161+
if (raw.endsWith('.*')) {
162+
traceWarn(
163+
`inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`,
164+
);
165+
return undefined;
166+
}
167+
const segments = parseReleaseSegments(raw);
168+
if (segments === undefined) {
169+
return undefined;
170+
}
171+
return { segments, display: segmentsToString(segments) };
172+
}
173+
case '==': {
174+
const literal = raw.endsWith('.*') ? raw.slice(0, -2) : raw;
175+
const segments = parseReleaseSegments(literal);
176+
if (segments === undefined) {
177+
return undefined;
178+
}
179+
return { segments, display: segmentsToString(segments) };
180+
}
181+
case '~=': {
182+
// PEP 440 requires at least two release segments and disallows
183+
// wildcards for `~=`. Both rejections mirror matchesPythonVersion.
184+
if (raw.endsWith('.*')) {
185+
traceWarn(
186+
`inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`,
187+
);
188+
return undefined;
189+
}
190+
const segments = parseReleaseSegments(raw);
191+
if (segments === undefined) {
192+
return undefined;
193+
}
194+
if (segments.length < 2) {
195+
traceWarn(
196+
`inline-script interpreter: '~=' requires at least two release segments: ${JSON.stringify(clause)}`,
197+
);
198+
return undefined;
199+
}
200+
return { segments, display: segmentsToString(segments) };
201+
}
202+
case '>':
203+
case '<':
204+
case '<=':
205+
case '!=':
206+
case '===':
207+
// No clean integer floor we can hand to `uv python install`.
208+
// Caller falls back to uv default and re-verifies post-install.
209+
return undefined;
210+
default:
211+
return undefined;
212+
}
213+
}
214+
215+
function segmentsToString(segments: ReadonlyArray<number>): string {
216+
return segments.join('.');
217+
}

0 commit comments

Comments
 (0)