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
7 changes: 7 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ 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 installUv = l10n.t('Install uv');
export const installUvAndPython = l10n.t('Install uv and Python');
export function installPythonVersion(version: string): string {
return l10n.t('Install Python {0}', version);
Expand Down Expand Up @@ -281,6 +282,12 @@ export namespace UvInstallStrings {
'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 function inlineScriptInstallUvForVersionLookupPrompt(requiresPython: string): string {
return l10n.t(
'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.',
requiresPython,
);
}
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
242 changes: 225 additions & 17 deletions src/managers/builtin/inlineScript/envManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import * as fs from 'fs-extra';
import * as path from 'path';
import { clean as cleanPep440, satisfies as satisfiesPep440 } from '@renovatebot/pep440';
import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, ThemeIcon, Uri } from 'vscode';
import {
CreateEnvironmentOptions,
Expand Down Expand Up @@ -31,19 +32,17 @@ import {
resolveCacheEntryPath,
writeMetaJson,
} from '../../../common/inlineScript/cacheLayout';
import { pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
import {
InlineScriptMetadata,
matchesPythonVersion,
readInlineScriptMetadataFromFile,
} from '../../../common/inlineScript/metadata';
import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata';
import { CONDA_MANAGER_ID, PYENV_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../../common/constants';
import { acquireFileLock, AcquiredFileLock } from '../../../common/lockfile.apis';
import { isFileNotFoundError } from '../../../common/utils/filesystem';
import { normalizePath } from '../../../common/utils/pathUtils';
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment';
import { NativePythonFinder } from '../../common/nativePythonFinder';
import { resolveSystemPythonEnvironmentPath } from '../utils';
import * as uvPythonInstaller from '../uvPythonInstaller';
import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils';

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

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

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

Expand Down Expand Up @@ -192,12 +196,24 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
}

private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise<SelectedBaseInterpreter | undefined> {
const globalEnvironments = await this.api.getEnvironments('global');
const reported = globalEnvironments.filter(
(environment) =>
BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) &&
(environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'),
);
let globalEnvironments: readonly PythonEnvironment[] = [];
try {
globalEnvironments = await this.api.getEnvironments('global');
} catch (error) {
this.log.warn(`Unable to query discovered base interpreters: ${getErrorMessage(error)}`);
}
const reported = [
...globalEnvironments.filter(
(environment) =>
BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) &&
(environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'),
),
...[...this.directlyResolvedBaseInterpreters.values()].filter(
(environment) =>
!metadata.requiresPython ||
this.matchesInstallConstraint(metadata.requiresPython, environment.version),
),
Comment thread
edvilme marked this conversation as resolved.
];
const derivedChecks = await Promise.all(
reported.map(async (environment) => {
if (!path.isAbsolute(environment.sysPrefix)) {
Expand All @@ -213,11 +229,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
}),
);
let candidates = derivedChecks
.filter((candidate) => !candidate.derived)
.filter(
(candidate) =>
!candidate.derived &&
(!metadata.requiresPython ||
this.matchesInstallConstraint(metadata.requiresPython, candidate.environment.version)),
)
.map((candidate) => candidate.environment);

while (candidates.length > 0) {
const environment = pickCompatibleInterpreter(candidates, metadata.requiresPython);
const environment = pickCompatibleInterpreter(candidates, undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const environment = pickCompatibleInterpreter(candidates, undefined);
const environment = pickCompatibleInterpreter(candidates);

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.

This is intentional just to call the helper to validate the already compatible candidates.

if (!environment) {
return undefined;
}
Expand All @@ -239,6 +260,193 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return undefined;
}

private async installAndSelectBaseInterpreter(
metadata: InlineScriptMetadata,
): Promise<SelectedBaseInterpreter | undefined> {
const run = this.baseInterpreterInstallationQueue.then(() =>
this.installAndSelectBaseInterpreterSerially(metadata),
);
// Keep the stored queue tail fulfilled so one failed request does not block later attempts;
// the caller still observes the original result through `run`.
this.baseInterpreterInstallationQueue = run.then(
() => undefined,
() => undefined,
);
Comment on lines +271 to +274

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not entirely sure what does this mean

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.

will add a comment

return run;
}

private async installAndSelectBaseInterpreterSerially(
metadata: InlineScriptMetadata,
): Promise<SelectedBaseInterpreter | undefined> {
const existing = await this.selectBaseInterpreter(metadata);
if (existing) {
return existing;
}

const requiresPython = metadata.requiresPython?.trim() || undefined;
const lowerBound = extractLowerBoundVersion(requiresPython);
const version = await this.selectInstallablePythonVersion(requiresPython, lowerBound);
if (requiresPython && !version) {
this.log.warn(
'Cannot install a Python for this inline script because no compatible install version could be selected.',
);
return undefined;
}

const installedPath = await this.installPythonAndRefresh(requiresPython, version);
if (!installedPath) {
return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we can try catching this?

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.

installPythonAndRefreshalready already catches prompt/install errors and refresh failures, while undefined is the expected result for a declined, cancelled, or handled failed installation.

}

let selected: SelectedBaseInterpreter | undefined;
try {
selected = await this.selectBaseInterpreter(metadata);
} catch (error) {
this.log.warn(
`Unable to refresh base-interpreter discovery after installing Python: ${getErrorMessage(error)}`,
);
}
if (!selected) {
const resolved = await resolveSystemPythonEnvironmentPath(
installedPath,
this.nativeFinder,
this.api,
this.baseManager,
);
const executable = resolved?.execInfo?.run.executable;
if (resolved && executable && pickCompatibleInterpreter([resolved], metadata.requiresPython)) {
try {
const canonicalPath = await fs.realpath(executable);
if (!requiresPython || this.matchesInstallConstraint(requiresPython, resolved.version)) {
this.directlyResolvedBaseInterpreters.set(canonicalPath, resolved);
selected = {
environment: resolved,
canonicalPath,
};
}
} catch (error) {
this.log.warn(
`Unable to resolve the Python installed for an inline script at ${executable}: ${getErrorMessage(error)}`,
);
}
}
}
if (!selected) {
this.log.warn(
'Python was installed for an inline script, but no compatible base interpreter was discovered after refreshing environments.',
);
}
return selected;
}

private async selectInstallablePythonVersion(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe this is a followup PR, but I think we could create a PythonVersion class that can handle extracting lower/upper bounds, testing regexes, comparing, ensuring versions match constraints, etc.

requiresPython: string | undefined,
lowerBound: string | undefined,
): Promise<string | undefined> {
if (!requiresPython) {
return lowerBound;
}
const prereleaseLowerBound = this.extractPrereleaseLowerBound(requiresPython);
if (prereleaseLowerBound) {
return prereleaseLowerBound;
}
const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined;
if (lowerBound && lowerBoundRelease?.[0] === 3) {
if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
return lowerBound;
}
if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) {
return lowerBound;
}
}

let available: uvPythonInstaller.UvPythonVersion[];
try {
if (!(await uvPythonInstaller.ensureUvForInlineScriptVersionLookup(requiresPython, this.log))) {
return undefined;
}
available = await uvPythonInstaller.getAvailablePythonVersions();
} catch (error) {
this.log.warn(`Unable to query Python versions available from uv: ${getErrorMessage(error)}`);
return undefined;
}
return available
.filter(
(candidate) =>
candidate.implementation === 'cpython' &&
candidate.variant === 'default' &&
candidate.version_parts.major === 3 &&
this.matchesInstallConstraint(requiresPython, candidate.version),
)
.sort((left, right) => {
const leftRelease = parseReleaseSegments(left.version);
const rightRelease = parseReleaseSegments(right.version);
if (!leftRelease || !rightRelease) {
return 0;
}
return compareReleaseSegments(rightRelease, leftRelease);
})[0]?.version;
}

private matchesInstallConstraint(requiresPython: string, version: string): boolean {
try {
return satisfiesPep440(version, requiresPython, {
prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test(
requiresPython,
),
});
} catch (error) {
this.log.warn(`Unable to evaluate requires-python '${requiresPython}': ${getErrorMessage(error)}`);
return false;
}
}
Comment thread
edvilme marked this conversation as resolved.

private extractPrereleaseLowerBound(requiresPython: string): string | undefined {
return requiresPython
.split(',')
.map((clause) =>
clause
.trim()
.match(
/^(?:>=|==|~=)\s*(\d+(?:\.\d+)*(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|[._-]?dev[._-]?\d+))$/i,
)?.[1],
)
.map((version) => (version ? cleanPep440(version) : undefined))
.filter((version): version is string => !!version)
.find((version) => this.matchesInstallConstraint(requiresPython, version));
}

private async installPythonAndRefresh(
requiresPython: string | undefined,
version: string | undefined,
): Promise<string | undefined> {
let installedPath: string | undefined;
try {
installedPath = await uvPythonInstaller.promptInstallPythonViaUv('inlineScript', this.log, {
requiresPython,
version,
});
if (!installedPath) {
this.log.warn(
'Python installation for inline-script environment creation was declined or did not complete.',
);
return undefined;
}
} catch (error) {
this.log.error(`Failed to install Python for an inline script: ${getErrorMessage(error)}`);
return undefined;
}

try {
await this.api.refreshEnvironments(undefined);
} catch (error) {
this.log.warn(
`Python was installed for an inline script, but environment discovery could not be refreshed: ${getErrorMessage(error)}`,
);
}
return installedPath;
}

private async createOrReuseEnvironment({
cacheKey,
packages,
Expand Down Expand Up @@ -363,7 +571,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return { kind: 'stale' };
}
const requiresPython = metadata.requiresPython?.trim();
if (requiresPython && !matchesPythonVersion(requiresPython, environment.version)) {
if (requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version)) {
return { kind: 'stale' };
}

Expand Down
28 changes: 27 additions & 1 deletion src/managers/builtin/uvPythonInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const MAX_PROMPT_DETAIL_LENGTH = 120;
const TASK_TIMEOUT_MS = 5 * 60 * 1000;

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

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

export async function ensureUvForInlineScriptVersionLookup(
requiresPython: string,
log?: LogOutputChannel,
): Promise<boolean> {
if (await isUvInstalled(log)) {
return true;
}
const displayedRequirement = sanitizePromptDetail(requiresPython);
if (!displayedRequirement) {
return false;
}
const selection = await showInformationMessage(
UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt(displayedRequirement),
{ modal: true },
UvInstallStrings.installUv,
);
if (selection !== UvInstallStrings.installUv || !(await installUv(log))) {
return false;
}
if (await isUvInstalled(log)) {
return true;
}
showErrorMessage(UvInstallStrings.uvInstallRestartRequired);
return false;
}

/**
* Gets the path to the uv-managed Python installation.
* Uses `uv python list --only-installed --managed-python` to find only uv-installed Pythons.
Expand Down
Loading
Loading