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
240 changes: 223 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 on lines +211 to +215

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
...[...this.directlyResolvedBaseInterpreters.values()].filter(
(environment) =>
!metadata.requiresPython ||
this.matchesInstallConstraint(metadata.requiresPython, environment.version),
),
...this.directlyResolvedBaseInterpreters.values().filter(
(environment) =>
!metadata.requiresPython ||
this.matchesInstallConstraint(metadata.requiresPython, environment.version),
),

];
const derivedChecks = await Promise.all(
reported.map(async (environment) => {
if (!path.isAbsolute(environment.sysPrefix)) {
Expand All @@ -214,10 +230,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
);
let candidates = derivedChecks
.filter((candidate) => !candidate.derived)
.map((candidate) => candidate.environment);
.map((candidate) => candidate.environment)
.filter(
(candidate) =>
!metadata.requiresPython ||
this.matchesInstallConstraint(metadata.requiresPython, candidate.version),
);
Comment on lines 231 to +238

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 thes can be combined into something like

let candidates = derivedChecks
    .filter((candidate) => 
        !candidate.derived 
        && (!metadata.requiresPython || this.matchesInstallConstraint(metadata.requiresPython, candidate.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);

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

private async installAndSelectBaseInterpreter(
metadata: InlineScriptMetadata,
): Promise<SelectedBaseInterpreter | undefined> {
const run = this.baseInterpreterInstallationQueue.then(() =>
this.installAndSelectBaseInterpreterSerially(metadata),
);
this.baseInterpreterInstallationQueue = run.then(
() => undefined,
() => undefined,
);
Comment on lines +269 to +272

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

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?

}

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 on lines +389 to +400

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 would probably extract this function off the class since it is not bound to an instance and can be reused elsewhere


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 +569,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