Skip to content
Open
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
196 changes: 196 additions & 0 deletions src/common/inlineScriptInterpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
Comment thread
StellaHuang95 marked this conversation as resolved.

import { PythonEnvironment } from '../api';
import { matchesPythonVersion } from './inlineScriptMetadata';
import { traceWarn } from './logging';
import { compareReleaseSegments, parseReleaseSegments } from './utils/pep440Release';

/**
* Pick the newest installed Python that can serve as a base interpreter for
* a PEP 723 script. Returns `undefined` if no candidate is usable. This
* result is not user consent to install anything: the caller must use the
* consent-gated uv install prompt or surface an actionable error.
*
* **Caller contract**: `installed` must contain only BASE interpreters
* (system Pythons, pyenv-installed, uv-installed, conda `base`) — never
* venvs / conda named envs / poetry / pipenv project envs. This function
* does not filter derived envs out, and using one as a venv base produces
* a nested or broken environment. `api.getEnvironments('global')` is the
* right source (with the caveat that pipenv's `'global'` scope is known
* to leak derived envs).
*/
export function pickCompatibleInterpreter(
installed: ReadonlyArray<PythonEnvironment>,
requiresPython: string | undefined,
): PythonEnvironment | undefined {
const constraint = requiresPython && requiresPython.length > 0 ? requiresPython : undefined;
const candidates = installed.filter((env) => isUsableBaseInterpreter(env, constraint));
if (candidates.length === 0) {
return undefined;
}
const sorted = [...candidates].sort((a, b) => compareVersionsDescending(a.version, b.version));
return sorted[0];
}

/**
* Extract a lower-bound version string from a PEP 440 `requires-python`
* specifier, suitable as the `version` argument to `uv python install`.
*
* Examples:
* ">=3.13" → "3.13"
* ">=3.11,<3.13" → "3.11" (tightest lower bound across clauses)
* "~=3.12.4" → "3.12.4"
* "==3.12.*" → "3.12"
* "==3.12.7" → "3.12.7"
*
* Returns `undefined` for specifiers without a clean lower bound (`<3.13`,
* `!=3.10`, `>3.12`, `===…`, illegal shapes like `~=3` or `>=3.*`). The
* caller falls back to the uv default and re-verifies with
* `matchesPythonVersion` after install.
*/
export function extractLowerBoundVersion(requiresPython: string | undefined): string | undefined {
if (!requiresPython) {
return undefined;
}
const clauses = requiresPython
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0);
if (clauses.length === 0) {
return undefined;
}

let best: number[] | undefined;
let bestStr: string | undefined;
for (const clause of clauses) {
const lb = lowerBoundForClause(clause);
if (lb === undefined) {
continue;
}
if (best === undefined || compareReleaseSegments(lb.segments, best) > 0) {
best = lb.segments;
bestStr = lb.display;
}
}
return bestStr;
}

function isUsableBaseInterpreter(env: PythonEnvironment, requiresPython: string | undefined): boolean {
if (env.error) {
return false;
}
if (typeof env.version !== 'string' || env.version.length === 0) {
return false;
}
if (parseLeadingMajor(env.version) !== 3) {
return false;
}
if (requiresPython !== undefined && !matchesPythonVersion(requiresPython, env.version)) {
return false;
}
return true;
}

function parseLeadingMajor(version: string): number | undefined {
const m = version.match(/^\s*(\d+)/);
if (!m) {
return undefined;
}
const n = Number.parseInt(m[1], 10);
return Number.isNaN(n) ? undefined : n;
}

function compareVersionsDescending(a: string, b: string): number {
const aSeg = parseReleaseSegments(a);
const bSeg = parseReleaseSegments(b);
if (aSeg === undefined && bSeg === undefined) {
return 0;
}
if (aSeg === undefined) {
return 1;
}
if (bSeg === undefined) {
return -1;
}
return compareReleaseSegments(bSeg, aSeg);
}

const CLAUSE_RE = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/;

interface LowerBound {
readonly segments: number[];
readonly display: string;
}

function lowerBoundForClause(clause: string): LowerBound | undefined {
const m = clause.match(CLAUSE_RE);
if (!m) {
traceWarn(`inline-script interpreter: unrecognized requires-python clause: ${JSON.stringify(clause)}`);
return undefined;
}
const op = m[1];
const raw = m[2].trim();

switch (op) {
case '>=': {
// Per PEP 440 wildcards are only legal with `==` / `!=`. Stay
// consistent with matchesPythonVersion (which rejects `>=X.*`)
// so we never hand uv a value the picker will then reject.
if (raw.endsWith('.*')) {
traceWarn(
`inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`,
);
return undefined;
}
const segments = parseReleaseSegments(raw);
if (segments === undefined) {
return undefined;
}
return { segments, display: segmentsToString(segments) };
}
case '==': {
const literal = raw.endsWith('.*') ? raw.slice(0, -2) : raw;
const segments = parseReleaseSegments(literal);
if (segments === undefined) {
return undefined;
}
return { segments, display: segmentsToString(segments) };
}
case '~=': {
// PEP 440 requires at least two release segments and disallows
// wildcards for `~=`. Both rejections mirror matchesPythonVersion.
if (raw.endsWith('.*')) {
traceWarn(
`inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`,
);
return undefined;
}
const segments = parseReleaseSegments(raw);
if (segments === undefined) {
return undefined;
}
if (segments.length < 2) {
traceWarn(
`inline-script interpreter: '~=' requires at least two release segments: ${JSON.stringify(clause)}`,
);
return undefined;
}
return { segments, display: segmentsToString(segments) };
}
case '>':
case '<':
case '<=':
case '!=':
case '===':
// No clean integer floor we can hand to `uv python install`.
// Caller falls back to uv default and re-verifies post-install.
return undefined;
default:
return undefined;
}
}

function segmentsToString(segments: ReadonlyArray<number>): string {
return segments.join('.');
}
43 changes: 6 additions & 37 deletions src/common/inlineScriptMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as tomljs from '@iarna/toml';
import * as fs from 'fs/promises';
import { Uri } from 'vscode';
import { traceVerbose, traceWarn } from './logging';
import { compareReleaseSegments, parseReleaseSegments } from './utils/pep440Release';

/**
* Parsed and validated PEP 723 `script` metadata block.
Expand Down Expand Up @@ -317,8 +318,8 @@ function matchSingleClause(clause: string, version: string): boolean {
);
return false;
}
const prefix = parseRelease(specVersion.slice(0, -2));
const ver = parseRelease(version);
const prefix = parseReleaseSegments(specVersion.slice(0, -2));
const ver = parseReleaseSegments(version);
if (prefix === undefined || ver === undefined) {
traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`);
return false;
Expand All @@ -327,14 +328,14 @@ function matchSingleClause(clause: string, version: string): boolean {
return op === '==' ? isPrefixMatch : !isPrefixMatch;
}

const specSegs = parseRelease(specVersion);
const verSegs = parseRelease(version);
const specSegs = parseReleaseSegments(specVersion);
const verSegs = parseReleaseSegments(version);
if (specSegs === undefined || verSegs === undefined) {
traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`);
return false;
}

const cmp = compareReleases(verSegs, specSegs);
const cmp = compareReleaseSegments(verSegs, specSegs);
switch (op) {
case '==':
return cmp === 0;
Expand Down Expand Up @@ -372,35 +373,3 @@ function matchSingleClause(clause: string, version: string): boolean {
return false;
}
}

function parseRelease(v: string): number[] | undefined {
let s = v.trim().replace(/^v/i, '');
// Strip optional epoch prefix `N!`.
const epoch = s.match(/^(\d+)!(.*)$/);
if (epoch) {
s = epoch[2];
}
// Take only the leading dotted-integer segments; PEP 440 release
// segments must be integers. Pre/post/dev/local suffixes are
// dropped, which is sufficient for `requires-python` matching.
const m = s.match(/^(\d+(?:\.\d+)*)/);
if (!m) {
return undefined;
}
return m[1].split('.').map((x) => parseInt(x, 10));
}

function compareReleases(a: readonly number[], b: readonly number[]): number {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av < bv) {
return -1;
}
if (av > bv) {
return 1;
}
}
return 0;
}
43 changes: 43 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,49 @@ export namespace UvInstallStrings {
'No Python found. Would you like to install uv and use it to install Python? This will download and run an installer from https://astral.sh.',
);
export const installPython = l10n.t('Install Python');
export const installUvAndPython = l10n.t('Install uv and Python');
export function installPythonVersion(version: string): string {
return l10n.t('Install Python {0}', version);
}
export function installUvAndPythonVersion(version: string): string {
return l10n.t('Install uv and Python {0}', version);
}
export function inlineScriptInstallPythonPrompt(requiresPython?: string, version?: string): string {
if (requiresPython && version) {
return l10n.t(
'No installed Python satisfies this script\'s requirement ({0}). Would you like to install Python {1} using uv?',
requiresPython,
version,
);
}
if (version) {
return l10n.t(
'No compatible Python is installed for this script. Would you like to install Python {0} using uv?',
version,
);
}
return l10n.t(
'No Python installation is available for this script. Would you like to install Python using uv?',
);
}
export function inlineScriptInstallPythonAndUvPrompt(requiresPython?: string, version?: string): string {
if (requiresPython && version) {
return l10n.t(
'No installed Python satisfies this script\'s requirement ({0}). Would you like to install uv and use it to install Python {1}? This will download and run an installer from https://astral.sh.',
requiresPython,
version,
);
}
if (version) {
return l10n.t(
'No compatible Python is installed for this script. Would you like to install uv and use it to install Python {0}? This will download and run an installer from https://astral.sh.',
version,
);
}
return l10n.t(
'No Python installation is available for this script. Would you like to install uv and use it to install Python? This will download and run an installer from https://astral.sh.',
);
}
export const installingUv = l10n.t('Installing uv...');
export const installingPython = l10n.t('Installing Python via uv...');
export const installComplete = l10n.t('Python installed successfully');
Expand Down
2 changes: 1 addition & 1 deletion src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ export interface IEventNamePropertyMapping {
}
*/
[EventNames.UV_PYTHON_INSTALL_PROMPTED]: {
trigger: 'activation' | 'createEnvironment';
trigger: 'activation' | 'createEnvironment' | 'inlineScript';
};

/* __GDPR__
Expand Down
45 changes: 45 additions & 0 deletions src/common/utils/pep440Release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/**
* 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.
*/
export function parseReleaseSegments(version: string): number[] | undefined {
let normalized = version.trim().replace(/^v/i, '');
const epoch = normalized.match(/^\d+!(.*)$/);
if (epoch) {
normalized = epoch[1];
}
const match = normalized.match(/^(\d+(?:\.\d+)*)/);
if (!match) {
return undefined;
}
return match[1].split('.').map((segment) => Number.parseInt(segment, 10));
}

/**
* Compare two PEP 440 release-segment arrays numerically.
*
* Missing trailing segments are treated as zero, so `3.12` and `3.12.0`
* compare as equal. Returns a negative number when `left` is older, zero when
* they are equal, and a positive number when `left` is newer.
*/
export function compareReleaseSegments(left: readonly number[], right: readonly number[]): number {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index++) {
const leftSegment = left[index] ?? 0;
const rightSegment = right[index] ?? 0;
if (leftSegment < rightSegment) {
return -1;
}
if (leftSegment > rightSegment) {
return 1;
}
}
return 0;
}
Loading
Loading