diff --git a/src/common/inlineScriptInterpreter.ts b/src/common/inlineScriptInterpreter.ts new file mode 100644 index 000000000..54754775e --- /dev/null +++ b/src/common/inlineScriptInterpreter.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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, + 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): string { + return segments.join('.'); +} diff --git a/src/common/inlineScriptMetadata.ts b/src/common/inlineScriptMetadata.ts index 5082eddf6..e2e8523cc 100644 --- a/src/common/inlineScriptMetadata.ts +++ b/src/common/inlineScriptMetadata.ts @@ -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. @@ -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; @@ -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; @@ -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; -} diff --git a/src/common/localize.ts b/src/common/localize.ts index d3ce5c10a..c18e7340b 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -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'); diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index 0c4a3fdf9..f9d396a85 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -311,7 +311,7 @@ export interface IEventNamePropertyMapping { } */ [EventNames.UV_PYTHON_INSTALL_PROMPTED]: { - trigger: 'activation' | 'createEnvironment'; + trigger: 'activation' | 'createEnvironment' | 'inlineScript'; }; /* __GDPR__ diff --git a/src/common/utils/pep440Release.ts b/src/common/utils/pep440Release.ts new file mode 100644 index 000000000..98a2a51b3 --- /dev/null +++ b/src/common/utils/pep440Release.ts @@ -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; +} \ No newline at end of file diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index 9b1fcdcc0..faab9ea7a 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -10,7 +10,7 @@ import { } from 'vscode'; import { spawnProcess } from '../../common/childProcess.apis'; import { Common, UvInstallStrings } from '../../common/localize'; -import { traceError, traceInfo, traceLog } from '../../common/logging'; +import { traceError, traceInfo, traceLog, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; import { executeTask, onDidEndTaskProcess } from '../../common/tasks.apis'; import { EventNames } from '../../common/telemetry/constants'; @@ -22,6 +22,28 @@ import { isUvInstalled, resetUvInstallationCache } from './helpers'; export const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; +export type UvPythonInstallTrigger = 'activation' | 'createEnvironment' | 'inlineScript'; + +export interface UvPythonInstallPromptOptions { + readonly version?: string; + readonly requiresPython?: string; +} + +const MAX_PROMPT_DETAIL_LENGTH = 120; +const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*$/; +const PROMPT_CONTROL_CHARACTERS = + /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g; + +function sanitizePromptDetail(value: string | undefined): string | undefined { + const normalized = value?.replace(PROMPT_CONTROL_CHARACTERS, ' ').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return undefined; + } + return normalized.length <= MAX_PROMPT_DETAIL_LENGTH + ? normalized + : `${normalized.slice(0, MAX_PROMPT_DETAIL_LENGTH - 3)}...`; +} + /** * Represents a Python version from uv python list */ @@ -183,9 +205,12 @@ export async function getUvPythonPath(version?: string): Promise v.version.startsWith(version) && v.path); + const match = versions.find( + (v) => (v.version === version || v.version.startsWith(`${version}.`)) && v.path, + ); resolve(match?.path ?? undefined); } else { // Return the first (latest) installed Python @@ -319,48 +344,72 @@ export async function installPythonViaUv(_log?: LogOutputChannel, version?: stri } /** - * Prompts the user to install Python via uv when no Python is found. + * Prompts the user to install Python via uv when no compatible Python is found. * Respects the "Don't ask again" setting. * - * @param trigger What triggered this prompt ('activation' or 'createEnvironment') + * @param trigger What triggered this prompt * @param log Optional log output channel + * @param options Optional version and script requirement shown to the user and passed to uv after consent * @returns Promise that resolves to the installed Python path, or undefined if not installed */ export async function promptInstallPythonViaUv( - trigger: 'activation' | 'createEnvironment', + trigger: UvPythonInstallTrigger, log?: LogOutputChannel, + options?: UvPythonInstallPromptOptions, ): Promise { - const state = await getGlobalPersistentState(); - const dontAsk = await state.get(UV_INSTALL_PYTHON_DONT_ASK_KEY); + const state = trigger === 'inlineScript' ? undefined : await getGlobalPersistentState(); + const dontAsk = await state?.get(UV_INSTALL_PYTHON_DONT_ASK_KEY); if (dontAsk) { traceLog('Skipping Python install prompt: user selected "Don\'t ask again"'); return undefined; } + const version = sanitizePromptDetail(options?.version); + const requiresPython = sanitizePromptDetail(options?.requiresPython); + + if (trigger === 'inlineScript' && version && !INSTALLABLE_PYTHON_VERSION.test(version)) { + traceWarn(`Skipping inline-script Python install prompt: invalid install version ${JSON.stringify(version)}`); + return undefined; + } + if (trigger === 'inlineScript' && requiresPython && !version) { + traceWarn('Skipping inline-script Python install prompt: no compatible install version was selected'); + return undefined; + } + sendTelemetryEvent(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { trigger }); - // Check if uv is installed to show appropriate message + // Check if uv is installed to show appropriate message. const uvInstalled = await isUvInstalled(log); - const promptMessage = uvInstalled - ? UvInstallStrings.installPythonPrompt - : UvInstallStrings.installPythonAndUvPrompt; - - const result = await showInformationMessage( - promptMessage, - { modal: true }, - UvInstallStrings.installPython, - Common.dontAskAgain, - ); - - if (result === Common.dontAskAgain) { + const promptMessage = + trigger === 'inlineScript' + ? uvInstalled + ? UvInstallStrings.inlineScriptInstallPythonPrompt(requiresPython, version) + : UvInstallStrings.inlineScriptInstallPythonAndUvPrompt(requiresPython, version) + : uvInstalled + ? UvInstallStrings.installPythonPrompt + : UvInstallStrings.installPythonAndUvPrompt; + const installAction = !uvInstalled + ? version + ? UvInstallStrings.installUvAndPythonVersion(version) + : UvInstallStrings.installUvAndPython + : version + ? UvInstallStrings.installPythonVersion(version) + : UvInstallStrings.installPython; + + const result = + trigger === 'inlineScript' + ? await showInformationMessage(promptMessage, { modal: true }, installAction) + : await showInformationMessage(promptMessage, { modal: true }, installAction, Common.dontAskAgain); + + if (result === Common.dontAskAgain && state) { await state.set(UV_INSTALL_PYTHON_DONT_ASK_KEY, true); traceLog('User selected "Don\'t ask again" for Python install prompt'); return undefined; } - if (result === UvInstallStrings.installPython) { - return await installPythonWithUv(log); + if (result === installAction) { + return await installPythonWithUv(log, version); } return undefined; diff --git a/src/test/common/inlineScriptInterpreter.unit.test.ts b/src/test/common/inlineScriptInterpreter.unit.test.ts new file mode 100644 index 000000000..b0ec8c979 --- /dev/null +++ b/src/test/common/inlineScriptInterpreter.unit.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { PythonEnvironment } from '../../api'; +import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../common/inlineScriptInterpreter'; +import * as logging from '../../common/logging'; + +function makeEnv(version: string, name = `Python ${version}`, error?: string): PythonEnvironment { + return { + envId: { id: name, managerId: 'ms-python.python:system' }, + name, + displayName: name, + displayPath: `/usr/bin/${name}`, + version, + environmentPath: Uri.file(`/usr/bin/${name}`), + execInfo: { run: { executable: `/usr/bin/${name}` } }, + sysPrefix: '/usr', + error, + }; +} + +suite('inlineScriptInterpreter', () => { + let traceWarnStub: sinon.SinonStub; + + setup(() => { + traceWarnStub = sinon.stub(logging, 'traceWarn'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('pickCompatibleInterpreter', () => { + test('returns undefined when the input list is empty', () => { + assert.strictEqual(pickCompatibleInterpreter([], undefined), undefined); + assert.strictEqual(pickCompatibleInterpreter([], '>=3.11'), undefined); + }); + + test('with no constraint, picks the newest non-errored Python 3', () => { + const envs = [makeEnv('3.10.5'), makeEnv('3.12.4'), makeEnv('3.11.2')]; + const picked = pickCompatibleInterpreter(envs, undefined); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('with a >= constraint, picks the newest version that satisfies it', () => { + const envs = [makeEnv('3.10.5'), makeEnv('3.12.4'), makeEnv('3.11.2'), makeEnv('3.13.0')]; + const picked = pickCompatibleInterpreter(envs, '>=3.11'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.13.0'); + }); + + test('with a multi-clause constraint, picks the newest version within the range', () => { + const envs = [makeEnv('3.10.5'), makeEnv('3.12.4'), makeEnv('3.11.2'), makeEnv('3.13.0')]; + const picked = pickCompatibleInterpreter(envs, '>=3.11,<3.13'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('returns undefined when no installed env satisfies the constraint', () => { + const envs = [makeEnv('3.10.5'), makeEnv('3.11.2')]; + assert.strictEqual(pickCompatibleInterpreter(envs, '>=3.13'), undefined); + }); + + test('skips envs with an error set even if their version would match', () => { + const envs = [makeEnv('3.13.0', 'broken', 'pyvenv.cfg missing'), makeEnv('3.12.4')]; + const picked = pickCompatibleInterpreter(envs, '>=3.11'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('skips envs whose version is empty', () => { + const envs = [makeEnv('', 'unknown'), makeEnv('3.12.4')]; + const picked = pickCompatibleInterpreter(envs, undefined); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('skips envs whose version does not parse as a leading integer', () => { + const envs = [makeEnv('not-a-version', 'broken-version'), makeEnv('3.12.4')]; + const picked = pickCompatibleInterpreter(envs, undefined); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('skips Python 2 even when no constraint is given', () => { + const envs = [makeEnv('2.7.18'), makeEnv('3.11.0')]; + const picked = pickCompatibleInterpreter(envs, undefined); + assert.ok(picked); + assert.strictEqual(picked.version, '3.11.0'); + }); + + test('returns undefined when only Python 2 is installed and no constraint is given', () => { + const envs = [makeEnv('2.7.18')]; + assert.strictEqual(pickCompatibleInterpreter(envs, undefined), undefined); + }); + + test('breaks version ties by input order (stable sort)', () => { + const a = makeEnv('3.12.4', 'first-3.12.4'); + const b = makeEnv('3.12.4', 'second-3.12.4'); + const picked = pickCompatibleInterpreter([a, b], undefined); + assert.strictEqual(picked, a); + const pickedReversed = pickCompatibleInterpreter([b, a], undefined); + assert.strictEqual(pickedReversed, b); + }); + + test('handles a mix of broken and version-unparseable entries cleanly', () => { + const envs = [ + makeEnv('', 'no-version'), + makeEnv('3.13.0', 'broken', 'install broken'), + makeEnv('not-a-version', 'garbage'), + makeEnv('3.11.7'), + ]; + const picked = pickCompatibleInterpreter(envs, '>=3.10'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.11.7'); + }); + + test('does not mutate the input array', () => { + const envs = [makeEnv('3.10.0'), makeEnv('3.12.0'), makeEnv('3.11.0')]; + const snapshot = envs.map((e) => e.version); + pickCompatibleInterpreter(envs, undefined); + assert.deepStrictEqual( + envs.map((e) => e.version), + snapshot, + ); + }); + + test('uses matchesPythonVersion semantics for wildcard specs (==3.12.*)', () => { + const envs = [makeEnv('3.12.0'), makeEnv('3.12.7'), makeEnv('3.13.0')]; + const picked = pickCompatibleInterpreter(envs, '==3.12.*'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.7'); + }); + + test('honors the implicit upper bound of a ~=X.Y.Z clause', () => { + // `~=3.12.4` ≡ `>=3.12.4, ==3.12.*`. 3.13.0 must NOT be picked. + const envs = [makeEnv('3.12.4'), makeEnv('3.12.7'), makeEnv('3.13.0')]; + const picked = pickCompatibleInterpreter(envs, '~=3.12.4'); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.7'); + }); + + test('returns undefined when only versions above the ~= cap are installed', () => { + const envs = [makeEnv('3.13.0'), makeEnv('3.14.0')]; + assert.strictEqual(pickCompatibleInterpreter(envs, '~=3.12.4'), undefined); + }); + + test('empty-string requiresPython is treated as no constraint (not "matches nothing")', () => { + const envs = [makeEnv('3.10.0'), makeEnv('3.12.4')]; + const picked = pickCompatibleInterpreter(envs, ''); + assert.ok(picked, 'empty constraint must not silently reject all envs'); + assert.strictEqual(picked.version, '3.12.4'); + }); + + test('ranks versions with pre-release / dev / local suffixes by release segments only', () => { + // 3.12.0a1 and 3.12.0.dev1 both parse to [3,12,0]; stable sort + // means the first-listed 3.12 entry wins. + const envs = [makeEnv('3.12.0a1'), makeEnv('3.11.0'), makeEnv('3.12.0.dev1')]; + const picked = pickCompatibleInterpreter(envs, undefined); + assert.ok(picked); + assert.strictEqual(picked.version, '3.12.0a1'); + }); + }); + + suite('extractLowerBoundVersion', () => { + test('returns undefined for undefined / empty / whitespace input', () => { + assert.strictEqual(extractLowerBoundVersion(undefined), undefined); + assert.strictEqual(extractLowerBoundVersion(''), undefined); + assert.strictEqual(extractLowerBoundVersion(' '), undefined); + assert.strictEqual(extractLowerBoundVersion(' , , '), undefined); + }); + + test('extracts the literal version from a >= clause', () => { + assert.strictEqual(extractLowerBoundVersion('>=3.13'), '3.13'); + assert.strictEqual(extractLowerBoundVersion('>=3.12.4'), '3.12.4'); + assert.strictEqual(extractLowerBoundVersion('>=3'), '3'); + }); + + test('extracts the literal version from a ~= clause', () => { + assert.strictEqual(extractLowerBoundVersion('~=3.12'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('~=3.12.4'), '3.12.4'); + }); + + test('extracts the literal version from an == clause (without wildcard)', () => { + assert.strictEqual(extractLowerBoundVersion('==3.12'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('==3.12.7'), '3.12.7'); + }); + + test('strips trailing .* from an == wildcard', () => { + assert.strictEqual(extractLowerBoundVersion('==3.12.*'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('==3.*'), '3'); + }); + + test('picks the tightest lower bound across multiple lower-bound clauses', () => { + assert.strictEqual(extractLowerBoundVersion('>=3.10,>=3.12'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('>=3.12,>=3.10'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('>=3.10.5,>=3.10.4'), '3.10.5'); + }); + + test('treats a mixed-clause spec as the lower bound of the lower-bound clauses only', () => { + assert.strictEqual(extractLowerBoundVersion('>=3.11,<3.13'), '3.11'); + assert.strictEqual(extractLowerBoundVersion('<3.13,>=3.11'), '3.11'); + assert.strictEqual(extractLowerBoundVersion('>=3.11,!=3.12.0,<3.13'), '3.11'); + }); + + test('returns undefined when there is no lower-bound clause', () => { + assert.strictEqual(extractLowerBoundVersion('<3.13'), undefined); + assert.strictEqual(extractLowerBoundVersion('<=3.12'), undefined); + assert.strictEqual(extractLowerBoundVersion('!=3.10'), undefined); + assert.strictEqual(extractLowerBoundVersion('<3.13,<=3.12'), undefined); + }); + + test('returns undefined for the > operator (no clean integer floor)', () => { + assert.strictEqual(extractLowerBoundVersion('>3.12'), undefined); + }); + + test('returns undefined for the === operator (opaque string shape)', () => { + assert.strictEqual(extractLowerBoundVersion('===3.12.0'), undefined); + }); + + test('returns undefined for an unrecognized clause and logs a traceWarn', () => { + assert.strictEqual(extractLowerBoundVersion('weird-thing'), undefined); + assert.ok(traceWarnStub.called); + }); + + test('tolerates whitespace around clauses and operators', () => { + assert.strictEqual(extractLowerBoundVersion(' >= 3.11 , < 3.13 '), '3.11'); + assert.strictEqual(extractLowerBoundVersion('>= 3.12.4'), '3.12.4'); + }); + + test('strips a leading v on the literal', () => { + assert.strictEqual(extractLowerBoundVersion('>=v3.12'), '3.12'); + assert.strictEqual(extractLowerBoundVersion('>=v3.12.4'), '3.12.4'); + }); + + test('rejects ~= with fewer than two release segments and logs a traceWarn', () => { + assert.strictEqual(extractLowerBoundVersion('~=3'), undefined); + assert.ok(traceWarnStub.called, 'expected a traceWarn for the illegal ~=3 clause'); + }); + + test('rejects wildcards on >= and ~= (only == / != accept .*)', () => { + assert.strictEqual(extractLowerBoundVersion('>=3.12.*'), undefined); + assert.strictEqual(extractLowerBoundVersion('>=3.*'), undefined); + assert.strictEqual(extractLowerBoundVersion('~=3.12.*'), undefined); + assert.ok(traceWarnStub.called); + }); + }); +}); diff --git a/src/test/common/utils/pep440Release.unit.test.ts b/src/test/common/utils/pep440Release.unit.test.ts new file mode 100644 index 000000000..fcc58e1b6 --- /dev/null +++ b/src/test/common/utils/pep440Release.unit.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; + +suite('pep440Release', () => { + suite('parseReleaseSegments', () => { + test('parses dotted numeric release segments', () => { + assert.deepStrictEqual(parseReleaseSegments('3.12.4'), [3, 12, 4]); + }); + + test('ignores syntax outside the release segments', () => { + assert.deepStrictEqual(parseReleaseSegments(' v2!3.12.4rc1.post2.dev3+local '), [3, 12, 4]); + }); + + test('returns undefined when no release segment is present', () => { + assert.strictEqual(parseReleaseSegments('not-a-version'), undefined); + }); + }); + + suite('compareReleaseSegments', () => { + test('pads missing trailing segments with zero', () => { + assert.strictEqual(compareReleaseSegments([3, 12], [3, 12, 0]), 0); + }); + + test('compares each segment numerically', () => { + assert.ok(compareReleaseSegments([3, 12, 10], [3, 12, 9]) > 0); + assert.ok(compareReleaseSegments([3, 11, 9], [3, 12]) < 0); + }); + }); +}); \ No newline at end of file diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 147eeb4e3..4190d33cc 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -1,11 +1,12 @@ import assert from 'assert'; import * as sinon from 'sinon'; -import { LogOutputChannel } from 'vscode'; +import { CancellationToken, LogOutputChannel, ShellExecution, TaskExecution, TaskProcessEndEvent } from 'vscode'; import * as childProcessApis from '../../../common/childProcess.apis'; import { Common, UvInstallStrings } from '../../../common/localize'; import * as persistentState from '../../../common/persistentState'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; +import * as taskApis from '../../../common/tasks.apis'; import * as windowApis from '../../../common/window.apis'; import * as helpers from '../../../managers/builtin/helpers'; import { @@ -84,7 +85,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { showInformationMessageStub.calledWith( UvInstallStrings.installPythonAndUvPrompt, { modal: true }, - UvInstallStrings.installPython, + UvInstallStrings.installUvAndPython, Common.dontAskAgain, ), 'Should show install Python AND uv prompt when uv is not installed', @@ -126,6 +127,204 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { 'Should send telemetry with createEnvironment trigger', ); }); + + test('should explain the requirement and requested version for an inline script', async () => { + mockState.get.resolves(true); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.13', + version: '3.13', + }); + + assert( + showInformationMessageStub.calledWithExactly( + UvInstallStrings.inlineScriptInstallPythonPrompt('>=3.13', '3.13'), + { modal: true }, + UvInstallStrings.installPythonVersion('3.13'), + ), + 'Should explain why the inline script needs another Python', + ); + assert(mockState.get.notCalled, 'Explicit inline-script setup should not be suppressed by another workflow'); + }); + + test('should disclose that uv will also be installed for an inline script', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.13', + version: '3.13', + }); + + assert( + showInformationMessageStub.calledWithExactly( + UvInstallStrings.inlineScriptInstallPythonAndUvPrompt('>=3.13', '3.13'), + { modal: true }, + UvInstallStrings.installUvAndPythonVersion('3.13'), + ), + 'Should disclose both installations before asking for consent', + ); + }); + + test('should not offer an install when no compatible version can be derived', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + + await promptInstallPythonViaUv('inlineScript', mockLog, { requiresPython: '<3.13' }); + + assert(showInformationMessageStub.notCalled, 'Should not offer an install that may violate the requirement'); + assert(isUvInstalledStub.notCalled, 'Should stop before checking or installing uv'); + assert(sendTelemetryEventStub.notCalled, 'Should not record a prompt that was not shown'); + }); + + test('should reject a non-numeric install version before prompting', async () => { + mockState.get.resolves(false); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.13', + version: 'latest\nInstall anyway', + }); + + assert(showInformationMessageStub.notCalled, 'Should not display or install an untrusted version value'); + assert(isUvInstalledStub.notCalled, 'Should stop before checking or installing uv'); + }); + + test('should trim inline-script context before displaying it', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: ' >=3.13 ', + version: ' 3.13 ', + }); + + assert( + showInformationMessageStub.calledWithExactly( + UvInstallStrings.inlineScriptInstallPythonPrompt('>=3.13', '3.13'), + { modal: true }, + UvInstallStrings.installPythonVersion('3.13'), + ), + 'Should display normalized prompt values', + ); + }); + + test('should flatten and cap a long requirement before displaying it', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + const requirement = `>=3.13\n${'a'.repeat(200)}`; + const displayedRequirement = `>=3.13 ${'a'.repeat(110)}...`; + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: requirement, + version: '3.13', + }); + + assert( + showInformationMessageStub.calledWithExactly( + UvInstallStrings.inlineScriptInstallPythonPrompt(displayedRequirement, '3.13'), + { modal: true }, + UvInstallStrings.installPythonVersion('3.13'), + ), + 'Should keep script-controlled prompt text compact and single-line', + ); + }); + + test('should strip invisible and bidirectional controls from the displayed requirement', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.13\u202e deceptive\u2069\u0000 text', + version: '3.13', + }); + + assert( + showInformationMessageStub.calledWithExactly( + UvInstallStrings.inlineScriptInstallPythonPrompt('>=3.13 deceptive text', '3.13'), + { modal: true }, + UvInstallStrings.installPythonVersion('3.13'), + ), + 'Should not let script metadata visually reorder or hide consent text', + ); + }); + + test('should send telemetry with the inline-script trigger', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + showInformationMessageStub.resolves(undefined); + + await promptInstallPythonViaUv('inlineScript', mockLog, { requiresPython: '>=3.13', version: '3.13' }); + + assert( + sendTelemetryEventStub.calledWith(EventNames.UV_PYTHON_INSTALL_PROMPTED, undefined, { + trigger: 'inlineScript', + }), + 'Should identify inline-script prompts in telemetry', + ); + }); + + test('should pass the approved inline-script version to uv', async () => { + mockState.get.resolves(false); + isUvInstalledStub.resolves(true); + const installAction = UvInstallStrings.installPythonVersion('3.13'); + showInformationMessageStub.onFirstCall().resolves(installAction); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task( + { report: () => undefined }, + { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => undefined }), + } as CancellationToken, + ), + ); + + let taskEndListener: ((event: TaskProcessEndEvent) => unknown) | undefined; + sinon.stub(taskApis, 'onDidEndTaskProcess').callsFake((listener) => { + taskEndListener = listener; + return { dispose: () => undefined }; + }); + const executeTaskStub = sinon.stub(taskApis, 'executeTask').callsFake(async (task) => { + setTimeout(() => { + taskEndListener?.({ execution: { task } as TaskExecution, exitCode: 0 } as TaskProcessEndEvent); + }, 0); + return { task, terminate: () => undefined } as TaskExecution; + }); + + const versions: UvPythonVersion[] = [ + makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }), + ]; + const mockProcess = new MockChildProcess('uv', [ + 'python', + 'list', + '--only-installed', + '--managed-python', + '--output-format', + 'json', + ]); + const spawnStub: sinon.SinonStub = sinon.stub(childProcessApis, 'spawnProcess'); + spawnStub.returns(mockProcess); + + const resultPromise = promptInstallPythonViaUv('inlineScript', mockLog, { + requiresPython: '>=3.13', + version: '3.13', + }); + setTimeout(() => { + mockProcess.stdout?.emit('data', JSON.stringify(versions)); + mockProcess.emit('exit', 0, null); + }, 10); + + assert.strictEqual(await resultPromise, '/usr/bin/python3.13'); + const installTask = executeTaskStub.firstCall.args[0]; + const execution = installTask.execution as ShellExecution; + assert.strictEqual(execution.command, 'uv'); + assert.deepStrictEqual(execution.args, ['python', 'install', '3.13']); + }); }); suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { @@ -264,6 +463,34 @@ suite('uvPythonInstaller - getUvPythonPath', () => { assert.strictEqual(result, '/usr/bin/python3.12', 'Should return the matching version'); }); + test('should match requested versions on release-segment boundaries', async () => { + const versions: UvPythonVersion[] = [ + makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }), + makeUvPythonVersion({ version: '3.1.9', path: '/usr/bin/python3.1' }), + ]; + + const mockProcess = new MockChildProcess('uv', [ + 'python', + 'list', + '--only-installed', + '--managed-python', + '--output-format', + 'json', + ]); + spawnStub.returns(mockProcess); + + const resultPromise = getUvPythonPath('3.1'); + + setTimeout(() => { + mockProcess.stdout?.emit('data', JSON.stringify(versions)); + mockProcess.emit('exit', 0, null); + }, 10); + + const result = await resultPromise; + + assert.strictEqual(result, '/usr/bin/python3.1', 'Should not mistake Python 3.13 for Python 3.1'); + }); + test('should return undefined when specified version is not found', async () => { const versions: UvPythonVersion[] = [makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' })];