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
218 changes: 218 additions & 0 deletions src/common/inlineScriptInterpreter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { PythonEnvironment } from '../api';
import { matchesPythonVersion } from './inlineScriptMetadata';
import { traceWarn } from './logging';

/**
* 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 parseReleaseSegments(version: string): number[] | undefined {
const m = version.match(/^v?(\d+(?:\.\d+)*)/i);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why concern for v?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason I mentioned below, a valid version specifier can have a leading v

if (!m) {
return undefined;
}
return m[1].split('.').map((s) => Number.parseInt(s, 10));
}

function compareReleaseSegments(a: ReadonlyArray<number>, b: ReadonlyArray<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;
}

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: 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
Loading
Loading