Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
162 changes: 153 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,56 @@ import { Installable } from '../common/types';
import { mergePackages } from '../common/utils';
import { refreshPipPackages } from './utils';

export function validatePyprojectToml(toml: tomljs.JsonMap): string | undefined {
// 1. Validate package name (PEP 508)
if (toml.project && (toml.project as tomljs.JsonMap).name) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can be simplified if you check toml.project and exit out early.
You can additionally have const project = toml.project as tomljs.JsonMap, you could even create a interface for the version and name fields that you need and avoid tomljs.JsonMap entirely.

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.

Sounds good, made some changes to address your feedback.

const name = (toml.project as tomljs.JsonMap).name as string;
// 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);
}
}

// 2. Validate version format (PEP 440)
if (toml.project && 'version' in (toml.project as tomljs.JsonMap)) {
const version = (toml.project as tomljs.JsonMap).version as string;
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);
}
}

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

// 4. Validate required "requires" field in [build-system] section (PEP 518)
if (toml['build-system']) {
const buildSystem = toml['build-system'] as tomljs.JsonMap;
// See PEP 518: https://peps.python.org/pep-0518/
if (!buildSystem.requires) {
return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.');
}
}

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 +128,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 +175,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 +200,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 +212,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 +294,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 +321,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