Skip to content
Merged
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 @@ -64,6 +64,13 @@ export namespace Pickers {
export const selectProject = l10n.t('Select a project, folder or script');
export const selectProjects = l10n.t('Select one or more projects, folders or scripts');
}

export namespace pyProject {
export const validationErrorAction = l10n.t(' What would you like to do?');
export const openFile = l10n.t('Open pyproject.toml');
export const continueAnyway = l10n.t('Continue Anyway');
export const cancel = l10n.t('Cancel');
}
}

export namespace ProjectViews {
Expand Down
169 changes: 160 additions & 9 deletions src/managers/builtin/pipUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as tomljs from '@iarna/toml';
import * as fse from 'fs-extra';
import * as path from 'path';
import { LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri } from 'vscode';
import { l10n, LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri, window } from 'vscode';
import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api';
import { EXTENSION_ROOT_DIR } from '../../common/constants';
import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize';
Expand All @@ -13,6 +13,63 @@ import { Installable } from '../common/types';
import { mergePackages } from '../common/utils';
import { refreshPipPackages } from './utils';

export interface PyprojectToml {
project?: {
name?: string;
version?: string;
};
'build-system'?: {
requires?: unknown;
};
}
export function validatePyprojectToml(toml: PyprojectToml): string | undefined {
// 1. Validate required "requires" field in [build-system] section (PEP 518)
const buildSystem = toml['build-system'];
if (buildSystem && !buildSystem.requires) {
// See PEP 518: https://peps.python.org/pep-0518/
return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.');
}

const project = toml.project;
if (!project) {
return undefined;
}

const name = project.name;
// 2. Validate required "name" field in [project] section (PEP 621)
// See PEP 621: https://peps.python.org/pep-0621/
if (!name) {
return l10n.t('Missing required field "name" in [project] section of pyproject.toml.');
}

// 3. Validate package name (PEP 508)
// PEP 508 regex: must start and end with a letter or digit, can contain -_., and alphanumeric characters. No spaces allowed.
// See https://peps.python.org/pep-0508/
const nameRegex = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$/;
if (!nameRegex.test(name)) {
return l10n.t('Invalid package name "{0}" in pyproject.toml.', name);
}

// 4. Validate version format (PEP 440)
const version = project.version;
if (version !== undefined) {
if (version.length === 0) {
return l10n.t('Version cannot be empty in pyproject.toml.');
}
// PEP 440 version regex. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3").
// See https://peps.python.org/pep-0440/
// This regex is adapted from the official python 'packaging' library:
// https://github.com/pypa/packaging/blob/main/src/packaging/version.py
const versionRegex =
/^v?([0-9]+!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i;
if (!versionRegex.test(version)) {
return l10n.t('Invalid version "{0}" in pyproject.toml.', version);
}
}

return undefined;
}

async function tomlParse(fsPath: string, log?: LogOutputChannel): Promise<tomljs.JsonMap> {
try {
const content = await fse.readFile(fsPath, 'utf-8');
Expand Down Expand Up @@ -78,11 +135,12 @@ async function getCommonPackages(): Promise<Installable[]> {
}

async function selectWorkspaceOrCommon(
installable: Installable[],
installableResult: ProjectInstallableResult,
common: Installable[],
showSkipOption: boolean,
installed: string[],
): Promise<PipPackages | undefined> {
const installable = installableResult.installables;
if (installable.length === 0 && common.length === 0) {
return undefined;
}
Expand Down Expand Up @@ -124,7 +182,20 @@ async function selectWorkspaceOrCommon(
if (selected && !Array.isArray(selected)) {
try {
if (selected.label === PackageManagement.workspaceDependencies) {
return await selectFromInstallableToInstall(installable, undefined, { showBackButton });
const selectedInstallables = await selectFromInstallableToInstall(installable, undefined, {
showBackButton,
});

const validationError = installableResult.validationError;
const shouldProceed = await shouldProceedAfterPyprojectValidation(
validationError,
selectedInstallables?.install ?? [],
);
if (!shouldProceed) {
return undefined;
}

return selectedInstallables;
} else if (selected.label === PackageManagement.searchCommonPackages) {
return await selectFromCommonPackagesToInstall(common, installed, undefined, { showBackButton });
} else if (selected.label === PackageManagement.skipPackageInstallation) {
Expand All @@ -136,7 +207,7 @@ async function selectWorkspaceOrCommon(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (ex: any) {
if (ex === QuickInputButtons.Back) {
return selectWorkspaceOrCommon(installable, common, showSkipOption, installed);
return selectWorkspaceOrCommon(installableResult, common, showSkipOption, installed);
}
}
}
Expand All @@ -148,32 +219,58 @@ export interface PipPackages {
uninstall: string[];
}

export interface ProjectInstallableResult {
/**
* List of installable packages from pyproject.toml file
*/
installables: Installable[];

/**
* Validation error information if pyproject.toml validation failed
*/
validationError?: ValidationError;
}

export interface ValidationError {
/**
* Human-readable error message describing the validation issue
*/
message: string;

/**
* URI to the pyproject.toml file that has the validation error
*/
fileUri: Uri;
}

export async function getWorkspacePackagesToInstall(
api: PythonEnvironmentApi,
options: PackageManagementOptions,
project?: PythonProject[],
environment?: PythonEnvironment,
log?: LogOutputChannel,
): Promise<PipPackages | undefined> {
const installable = (await getProjectInstallable(api, project)) ?? [];
const installableResult = await getProjectInstallable(api, project);
let common = await getCommonPackages();
let installed: string[] | undefined;
if (environment) {
installed = (await refreshPipPackages(environment, log, { showProgress: true }))?.map((pkg) => pkg.name);
common = mergePackages(common, installed ?? []);
}
return selectWorkspaceOrCommon(installable, common, !!options.showSkipOption, installed ?? []);
return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []);
}

export async function getProjectInstallable(
api: PythonEnvironmentApi,
projects?: PythonProject[],
): Promise<Installable[]> {
): Promise<ProjectInstallableResult> {
if (!projects) {
return [];
return { installables: [] };
}
const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**';
const installable: Installable[] = [];
let validationError: { message: string; fileUri: Uri } | undefined;

await withProgress(
{
location: ProgressLocation.Notification,
Expand Down Expand Up @@ -204,6 +301,18 @@ export async function getProjectInstallable(
filtered.map(async (uri) => {
if (uri.fsPath.endsWith('.toml')) {
const toml = await tomlParse(uri.fsPath);

// Validate pyproject.toml
if (!validationError) {
const error = validatePyprojectToml(toml);
if (error) {
validationError = {
message: error,
fileUri: uri,
};
}
}

installable.push(...getTomlInstallable(toml, uri));
} else {
const name = path.basename(uri.fsPath);
Expand All @@ -219,7 +328,49 @@ export async function getProjectInstallable(
);
},
);
return installable;

return {
installables: installable,
validationError,
};
}

export async function shouldProceedAfterPyprojectValidation(
validationError: ValidationError | undefined,
install: string[],
): Promise<boolean> {
// 1. If no validation error or no installables selected, proceed
if (!validationError || install.length === 0) {
return true;
}

const selectedTomlInstallables = install.some((arg, index, arr) => arg === '-e' && index + 1 < arr.length);
if (!selectedTomlInstallables) {
// 2. If no toml installables selected, proceed
return true;
}

// 3. Otherwise, show error message and ask user what to do
const openButton = { title: Pickers.pyProject.openFile };
const continueButton = { title: Pickers.pyProject.continueAnyway };
const cancelButton = { title: Pickers.pyProject.cancel, isCloseAffordance: true };

const selection = await window.showErrorMessage(
validationError.message + Pickers.pyProject.validationErrorAction,
openButton,
continueButton,
cancelButton,
);

if (selection === continueButton) {
return true;
}

if (selection === openButton) {
await window.showTextDocument(validationError.fileUri);
}

return false;
}

export function isPipInstallCommand(command: string): boolean {
Expand Down
4 changes: 1 addition & 3 deletions src/managers/builtin/venvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,7 @@ export class VenvManager implements EnvironmentManager {
}
} else if (result?.envCreationErr) {
// Show error message to user when environment creation failed
showErrorMessage(
l10n.t('Failed to create virtual environment: {0}', result.envCreationErr),
);
showErrorMessage(l10n.t('Failed to create virtual environment: {0}', result.envCreationErr));
}
return result?.environment ?? undefined;
} finally {
Expand Down
17 changes: 15 additions & 2 deletions src/managers/builtin/venvStepBasedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { EventNames } from '../../common/telemetry/constants';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis';
import { NativePythonFinder } from '../common/nativePythonFinder';
import { getProjectInstallable, getWorkspacePackagesToInstall, PipPackages } from './pipUtils';
import {
getProjectInstallable,
getWorkspacePackagesToInstall,
PipPackages,
shouldProceedAfterPyprojectValidation,
} from './pipUtils';
import { CreateEnvironmentResult, createWithProgress, ensureGlobalEnv } from './venvUtils';

/**
Expand Down Expand Up @@ -335,12 +340,20 @@ export async function createStepBasedVenvFlow(

// Get workspace dependencies to install
const project = api.getPythonProject(venvRoot);
const installables = await getProjectInstallable(api, project ? [project] : undefined);
const result = await getProjectInstallable(api, project ? [project] : undefined);
const installables = result.installables;
const allPackages = [];
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
if (options.additionalPackages) {
allPackages.push(...options.additionalPackages);
}

const validationError = result.validationError;
const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages);
if (!shouldProceed) {
return undefined;
}

return await createWithProgress(nativeFinder, api, log, manager, state.basePython, venvRoot, quickEnvPath, {
install: allPackages,
uninstall: [],
Expand Down
11 changes: 9 additions & 2 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from '../common/nativePythonFinder';
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
import { runPython, runUV, shouldUseUv } from './helpers';
import { getProjectInstallable, PipPackages } from './pipUtils';
import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils';
import { resolveSystemPythonEnvironmentPath } from './utils';
import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments';
import { createStepBasedVenvFlow } from './venvStepBasedFlow';
Expand Down Expand Up @@ -396,13 +396,20 @@ export async function quickCreateVenv(
const project = api.getPythonProject(venvRoot);

sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'quick' });
const installables = await getProjectInstallable(api, project ? [project] : undefined);
const result = await getProjectInstallable(api, project ? [project] : undefined);
const installables = result.installables;
const allPackages = [];
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
if (additionalPackages) {
allPackages.push(...additionalPackages);
}

const validationError = result.validationError;
const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages);
if (!shouldProceed) {
return undefined;
}

// Check if .venv already exists
let venvPath = path.join(venvRoot.fsPath, '.venv');
if (await fsapi.pathExists(venvPath)) {
Expand Down
Loading