Skip to content

Commit 2c8302b

Browse files
Add uv fallback for inline script environments
Add consent-gated uv installation when no installed interpreter satisfies a script. Coalesce matching installs, skip prompts for quick create, and directly resolve a successful installation when discovery is stale or unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a9f6ba1-9bd3-4664-bc25-a0d34d7a2e91
1 parent 57f4248 commit 2c8302b

5 files changed

Lines changed: 824 additions & 20 deletions

File tree

‎src/common/localize.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export namespace UvInstallStrings {
238238
'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.',
239239
);
240240
export const installPython = l10n.t('Install Python');
241+
export const installUv = l10n.t('Install uv');
241242
export const installUvAndPython = l10n.t('Install uv and Python');
242243
export function installPythonVersion(version: string): string {
243244
return l10n.t('Install Python {0}', version);
@@ -281,6 +282,12 @@ export namespace UvInstallStrings {
281282
'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.',
282283
);
283284
}
285+
export function inlineScriptInstallUvForVersionLookupPrompt(requiresPython: string): string {
286+
return l10n.t(
287+
'No installed Python satisfies this script\'s requirement ({0}). Install uv to find a compatible Python version? This will download and run an installer from https://astral.sh.',
288+
requiresPython,
289+
);
290+
}
284291
export const installingUv = l10n.t('Installing uv...');
285292
export const installingPython = l10n.t('Installing Python via uv...');
286293
export const installComplete = l10n.t('Python installed successfully');

‎src/managers/builtin/inlineScript/envManager.ts‎

Lines changed: 223 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import * as fs from 'fs-extra';
55
import * as path from 'path';
6+
import { clean as cleanPep440, satisfies as satisfiesPep440 } from '@renovatebot/pep440';
67
import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, ThemeIcon, Uri } from 'vscode';
78
import {
89
CreateEnvironmentOptions,
@@ -31,19 +32,17 @@ import {
3132
resolveCacheEntryPath,
3233
writeMetaJson,
3334
} from '../../../common/inlineScript/cacheLayout';
34-
import { pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
35-
import {
36-
InlineScriptMetadata,
37-
matchesPythonVersion,
38-
readInlineScriptMetadataFromFile,
39-
} from '../../../common/inlineScript/metadata';
35+
import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
36+
import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata';
4037
import { CONDA_MANAGER_ID, PYENV_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../../common/constants';
4138
import { acquireFileLock, AcquiredFileLock } from '../../../common/lockfile.apis';
4239
import { isFileNotFoundError } from '../../../common/utils/filesystem';
4340
import { normalizePath } from '../../../common/utils/pathUtils';
4441
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
4542
import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment';
4643
import { NativePythonFinder } from '../../common/nativePythonFinder';
44+
import { resolveSystemPythonEnvironmentPath } from '../utils';
45+
import * as uvPythonInstaller from '../uvPythonInstaller';
4746
import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils';
4847

4948
const BASE_INTERPRETER_MANAGER_IDS = new Set([
@@ -79,6 +78,8 @@ type CacheEntryInspection =
7978
/** Manages extension-owned PEP 723 script environments. */
8079
export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
8180
private readonly pendingCreations = new Map<string, Promise<PythonEnvironment | undefined>>();
81+
private readonly directlyResolvedBaseInterpreters = new Map<string, PythonEnvironment>();
82+
private baseInterpreterInstallationQueue: Promise<void> = Promise.resolve();
8283

8384
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
8485
public readonly onDidChangeEnvironments: Event<DidChangeEnvironmentsEventArgs> =
@@ -131,9 +132,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
131132
return undefined;
132133
}
133134

134-
const selectedBase = await this.selectBaseInterpreter(metadata);
135+
let selectedBase = await this.selectBaseInterpreter(metadata);
136+
if (!selectedBase && options?.quickCreate !== true) {
137+
selectedBase = await this.installAndSelectBaseInterpreter(metadata);
138+
}
135139
if (!selectedBase) {
136-
this.log.warn(`No installed Python satisfies the inline-script requirements for ${scriptUri.fsPath}.`);
140+
this.log.warn(`No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`);
137141
return undefined;
138142
}
139143

@@ -192,12 +196,24 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
192196
}
193197

194198
private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise<SelectedBaseInterpreter | undefined> {
195-
const globalEnvironments = await this.api.getEnvironments('global');
196-
const reported = globalEnvironments.filter(
197-
(environment) =>
198-
BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) &&
199-
(environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'),
200-
);
199+
let globalEnvironments: readonly PythonEnvironment[] = [];
200+
try {
201+
globalEnvironments = await this.api.getEnvironments('global');
202+
} catch (error) {
203+
this.log.warn(`Unable to query discovered base interpreters: ${getErrorMessage(error)}`);
204+
}
205+
const reported = [
206+
...globalEnvironments.filter(
207+
(environment) =>
208+
BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) &&
209+
(environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'),
210+
),
211+
...[...this.directlyResolvedBaseInterpreters.values()].filter(
212+
(environment) =>
213+
!metadata.requiresPython ||
214+
this.matchesInstallConstraint(metadata.requiresPython, environment.version),
215+
),
216+
];
201217
const derivedChecks = await Promise.all(
202218
reported.map(async (environment) => {
203219
if (!path.isAbsolute(environment.sysPrefix)) {
@@ -214,10 +230,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
214230
);
215231
let candidates = derivedChecks
216232
.filter((candidate) => !candidate.derived)
217-
.map((candidate) => candidate.environment);
233+
.map((candidate) => candidate.environment)
234+
.filter(
235+
(candidate) =>
236+
!metadata.requiresPython ||
237+
this.matchesInstallConstraint(metadata.requiresPython, candidate.version),
238+
);
218239

219240
while (candidates.length > 0) {
220-
const environment = pickCompatibleInterpreter(candidates, metadata.requiresPython);
241+
const environment = pickCompatibleInterpreter(candidates, undefined);
221242
if (!environment) {
222243
return undefined;
223244
}
@@ -239,6 +260,191 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
239260
return undefined;
240261
}
241262

263+
private async installAndSelectBaseInterpreter(
264+
metadata: InlineScriptMetadata,
265+
): Promise<SelectedBaseInterpreter | undefined> {
266+
const run = this.baseInterpreterInstallationQueue.then(() =>
267+
this.installAndSelectBaseInterpreterSerially(metadata),
268+
);
269+
this.baseInterpreterInstallationQueue = run.then(
270+
() => undefined,
271+
() => undefined,
272+
);
273+
return run;
274+
}
275+
276+
private async installAndSelectBaseInterpreterSerially(
277+
metadata: InlineScriptMetadata,
278+
): Promise<SelectedBaseInterpreter | undefined> {
279+
const existing = await this.selectBaseInterpreter(metadata);
280+
if (existing) {
281+
return existing;
282+
}
283+
284+
const requiresPython = metadata.requiresPython?.trim() || undefined;
285+
const lowerBound = extractLowerBoundVersion(requiresPython);
286+
const version = await this.selectInstallablePythonVersion(requiresPython, lowerBound);
287+
if (requiresPython && !version) {
288+
this.log.warn(
289+
'Cannot install a Python for this inline script because no compatible install version could be selected.',
290+
);
291+
return undefined;
292+
}
293+
294+
const installedPath = await this.installPythonAndRefresh(requiresPython, version);
295+
if (!installedPath) {
296+
return undefined;
297+
}
298+
299+
let selected: SelectedBaseInterpreter | undefined;
300+
try {
301+
selected = await this.selectBaseInterpreter(metadata);
302+
} catch (error) {
303+
this.log.warn(
304+
`Unable to refresh base-interpreter discovery after installing Python: ${getErrorMessage(error)}`,
305+
);
306+
}
307+
if (!selected) {
308+
const resolved = await resolveSystemPythonEnvironmentPath(
309+
installedPath,
310+
this.nativeFinder,
311+
this.api,
312+
this.baseManager,
313+
);
314+
const executable = resolved?.execInfo?.run.executable;
315+
if (resolved && executable && pickCompatibleInterpreter([resolved], metadata.requiresPython)) {
316+
try {
317+
const canonicalPath = await fs.realpath(executable);
318+
if (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) {
319+
this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved);
320+
selected = {
321+
environment: resolved,
322+
canonicalPath,
323+
};
324+
}
325+
} catch (error) {
326+
this.log.warn(
327+
`Unable to resolve the Python installed for an inline script at ${executable}: ${getErrorMessage(error)}`,
328+
);
329+
}
330+
}
331+
}
332+
if (!selected) {
333+
this.log.warn(
334+
'Python was installed for an inline script, but no compatible base interpreter was discovered after refreshing environments.',
335+
);
336+
}
337+
return selected;
338+
}
339+
340+
private async selectInstallablePythonVersion(
341+
requiresPython: string | undefined,
342+
lowerBound: string | undefined,
343+
): Promise<string | undefined> {
344+
if (!requiresPython) {
345+
return lowerBound;
346+
}
347+
const prereleaseLowerBound = this.extractPrereleaseLowerBound(requiresPython);
348+
if (prereleaseLowerBound) {
349+
return prereleaseLowerBound;
350+
}
351+
const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined;
352+
if (lowerBound && lowerBoundRelease?.[0] === 3) {
353+
if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
354+
return lowerBound;
355+
}
356+
if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
357+
return lowerBound;
358+
}
359+
}
360+
361+
let available: uvPythonInstaller.UvPythonVersion[];
362+
try {
363+
if (!(await uvPythonInstaller.ensureUvForInlineScriptVersionLookup(requiresPython, this.log))) {
364+
return undefined;
365+
}
366+
available = await uvPythonInstaller.getAvailablePythonVersions();
367+
} catch (error) {
368+
this.log.warn(`Unable to query Python versions available from uv: ${getErrorMessage(error)}`);
369+
return undefined;
370+
}
371+
return available
372+
.filter(
373+
(candidate) =>
374+
candidate.implementation === 'cpython' &&
375+
candidate.variant === 'default' &&
376+
candidate.version_parts.major === 3 &&
377+
this.matchesInstallConstraint(requiresPython, candidate.version),
378+
)
379+
.sort((left, right) => {
380+
const leftRelease = parseReleaseSegments(left.version);
381+
const rightRelease = parseReleaseSegments(right.version);
382+
if (!leftRelease || !rightRelease) {
383+
return 0;
384+
}
385+
return compareReleaseSegments(rightRelease, leftRelease);
386+
})[0]?.version;
387+
}
388+
389+
private matchesInstallConstraint(requiresPython: string, version: string): boolean {
390+
try {
391+
return satisfiesPep440(version, requiresPython, {
392+
prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test(
393+
requiresPython,
394+
),
395+
});
396+
} catch (error) {
397+
this.log.warn(`Unable to evaluate requires-python '${requiresPython}': ${getErrorMessage(error)}`);
398+
return false;
399+
}
400+
}
401+
402+
private extractPrereleaseLowerBound(requiresPython: string): string | undefined {
403+
return requiresPython
404+
.split(',')
405+
.map((clause) =>
406+
clause
407+
.trim()
408+
.match(
409+
/^(?:>=|==|~=)\s*(\d+(?:\.\d+)*(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|[._-]?dev[._-]?\d+))$/i,
410+
)?.[1],
411+
)
412+
.map((version) => (version ? cleanPep440(version) : undefined))
413+
.filter((version): version is string => !!version)
414+
.find((version) => this.matchesInstallConstraint(requiresPython, version));
415+
}
416+
417+
private async installPythonAndRefresh(
418+
requiresPython: string | undefined,
419+
version: string | undefined,
420+
): Promise<string | undefined> {
421+
let installedPath: string | undefined;
422+
try {
423+
installedPath = await uvPythonInstaller.promptInstallPythonViaUv('inlineScript', this.log, {
424+
requiresPython,
425+
version,
426+
});
427+
if (!installedPath) {
428+
this.log.warn(
429+
'Python installation for inline-script environment creation was declined or did not complete.',
430+
);
431+
return undefined;
432+
}
433+
} catch (error) {
434+
this.log.error(`Failed to install Python for an inline script: ${getErrorMessage(error)}`);
435+
return undefined;
436+
}
437+
438+
try {
439+
await this.api.refreshEnvironments(undefined);
440+
} catch (error) {
441+
this.log.warn(
442+
`Python was installed for an inline script, but environment discovery could not be refreshed: ${getErrorMessage(error)}`,
443+
);
444+
}
445+
return installedPath;
446+
}
447+
242448
private async createOrReuseEnvironment({
243449
cacheKey,
244450
packages,
@@ -363,7 +569,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
363569
return { kind: 'stale' };
364570
}
365571
const requiresPython = metadata.requiresPython?.trim();
366-
if (requiresPython && !matchesPythonVersion(requiresPython, environment.version)) {
572+
if (requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version)) {
367573
return { kind: 'stale' };
368574
}
369575

‎src/managers/builtin/uvPythonInstaller.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const MAX_PROMPT_DETAIL_LENGTH = 120;
2626
const TASK_TIMEOUT_MS = 5 * 60 * 1000;
2727

2828
// Accept only numeric release segments before forwarding script-controlled input to uv.
29-
const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*$/;
29+
const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.dev\d+)?$/i;
3030

3131
// Remove C0/C1 controls and Unicode zero-width/bidirectional formatting characters
3232
// before displaying script-controlled text in a modal prompt.
@@ -185,6 +185,32 @@ export async function installUv(_log?: LogOutputChannel): Promise<boolean> {
185185
return success;
186186
}
187187

188+
export async function ensureUvForInlineScriptVersionLookup(
189+
requiresPython: string,
190+
log?: LogOutputChannel,
191+
): Promise<boolean> {
192+
if (await isUvInstalled(log)) {
193+
return true;
194+
}
195+
const displayedRequirement = sanitizePromptDetail(requiresPython);
196+
if (!displayedRequirement) {
197+
return false;
198+
}
199+
const selection = await showInformationMessage(
200+
UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt(displayedRequirement),
201+
{ modal: true },
202+
UvInstallStrings.installUv,
203+
);
204+
if (selection !== UvInstallStrings.installUv || !(await installUv(log))) {
205+
return false;
206+
}
207+
if (await isUvInstalled(log)) {
208+
return true;
209+
}
210+
showErrorMessage(UvInstallStrings.uvInstallRestartRequired);
211+
return false;
212+
}
213+
188214
/**
189215
* Gets the path to the uv-managed Python installation.
190216
* Uses `uv python list --only-installed --managed-python` to find only uv-installed Pythons.

0 commit comments

Comments
 (0)