Skip to content

Commit 98cc93a

Browse files
Validate pyproject.toml before installing dependencies (#1066)
This PR implements validation for `pyproject.toml` when installing dependencies, addressing issue #411. It's an initial implementation that covers some most common formatting errors, we can definitely expand coverage as we receive user feedback. This PR 1. Ensured `pyproject.toml` adheres to relevant PEP standards (PEP 508, PEP 440, PEP 621, PEP 518) before attempting to install packages from it and provides feedback when validation fails, allowing the user to fix the file or proceed if they choose. It added `validatePyprojectToml` in `pipUtils.ts` which checks: - Package Name (PEP 508): Validates name format (alphanumeric, dots, hyphens, underscores). <img width="461" height="96" alt="image" src="https://github.com/user-attachments/assets/ca5aa95f-7bc8-47cf-a5c7-80f048e0feac" /> - Version Format (PEP 440): Validates version strings against the standard (including pre/post/dev/local releases). <img width="450" height="91" alt="image" src="https://github.com/user-attachments/assets/1c0fb176-6ade-45a4-a0fe-356f86695f07" /> - Required Fields (PEP 621): Checks for the presence of the required name field in the [project] section. <img width="447" height="83" alt="image" src="https://github.com/user-attachments/assets/5762f22e-a4ce-4553-8185-efd9f4b9f37e" /> - Build System (PEP 518): Checks for the presence of the required requires field in the [build-system] section. <img width="446" height="92" alt="image" src="https://github.com/user-attachments/assets/5ea2559e-0704-40cb-a763-e2ac8b92601d" /> 2. Implemented `shouldProceedAfterPyprojectValidation` to display an error message with options to "Open pyproject.toml", "Continue Anyway", or "Cancel", only when user selects dependencies from incorrectly formatted `pypyroject.toml` file to install when creating environment. Example user flow: https://github.com/user-attachments/assets/4e919f2a-1934-4b8e-80d3-579880830b5a 3. Added a new test file `helpers.validatePyproject.unit.test.ts` with comprehensive unit tests.
1 parent f026743 commit 98cc93a

File tree

7 files changed

+668
-21
lines changed

7 files changed

+668
-21
lines changed

src/common/localize.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ export namespace Pickers {
6464
export const selectProject = l10n.t('Select a project, folder or script');
6565
export const selectProjects = l10n.t('Select one or more projects, folders or scripts');
6666
}
67+
68+
export namespace pyProject {
69+
export const validationErrorAction = l10n.t(' What would you like to do?');
70+
export const openFile = l10n.t('Open pyproject.toml');
71+
export const continueAnyway = l10n.t('Continue Anyway');
72+
export const cancel = l10n.t('Cancel');
73+
}
6774
}
6875

6976
export namespace ProjectViews {

src/managers/builtin/pipUtils.ts

Lines changed: 160 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as tomljs from '@iarna/toml';
22
import * as fse from 'fs-extra';
33
import * as path from 'path';
4-
import { LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri } from 'vscode';
4+
import { l10n, LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri, window } from 'vscode';
55
import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api';
66
import { EXTENSION_ROOT_DIR } from '../../common/constants';
77
import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize';
@@ -13,6 +13,63 @@ import { Installable } from '../common/types';
1313
import { mergePackages } from '../common/utils';
1414
import { refreshPipPackages } from './utils';
1515

16+
export interface PyprojectToml {
17+
project?: {
18+
name?: string;
19+
version?: string;
20+
};
21+
'build-system'?: {
22+
requires?: unknown;
23+
};
24+
}
25+
export function validatePyprojectToml(toml: PyprojectToml): string | undefined {
26+
// 1. Validate required "requires" field in [build-system] section (PEP 518)
27+
const buildSystem = toml['build-system'];
28+
if (buildSystem && !buildSystem.requires) {
29+
// See PEP 518: https://peps.python.org/pep-0518/
30+
return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.');
31+
}
32+
33+
const project = toml.project;
34+
if (!project) {
35+
return undefined;
36+
}
37+
38+
const name = project.name;
39+
// 2. Validate required "name" field in [project] section (PEP 621)
40+
// See PEP 621: https://peps.python.org/pep-0621/
41+
if (!name) {
42+
return l10n.t('Missing required field "name" in [project] section of pyproject.toml.');
43+
}
44+
45+
// 3. Validate package name (PEP 508)
46+
// PEP 508 regex: must start and end with a letter or digit, can contain -_., and alphanumeric characters. No spaces allowed.
47+
// See https://peps.python.org/pep-0508/
48+
const nameRegex = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$/;
49+
if (!nameRegex.test(name)) {
50+
return l10n.t('Invalid package name "{0}" in pyproject.toml.', name);
51+
}
52+
53+
// 4. Validate version format (PEP 440)
54+
const version = project.version;
55+
if (version !== undefined) {
56+
if (version.length === 0) {
57+
return l10n.t('Version cannot be empty in pyproject.toml.');
58+
}
59+
// PEP 440 version regex. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3").
60+
// See https://peps.python.org/pep-0440/
61+
// This regex is adapted from the official python 'packaging' library:
62+
// https://github.com/pypa/packaging/blob/main/src/packaging/version.py
63+
const versionRegex =
64+
/^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;
65+
if (!versionRegex.test(version)) {
66+
return l10n.t('Invalid version "{0}" in pyproject.toml.', version);
67+
}
68+
}
69+
70+
return undefined;
71+
}
72+
1673
async function tomlParse(fsPath: string, log?: LogOutputChannel): Promise<tomljs.JsonMap> {
1774
try {
1875
const content = await fse.readFile(fsPath, 'utf-8');
@@ -78,11 +135,12 @@ async function getCommonPackages(): Promise<Installable[]> {
78135
}
79136

80137
async function selectWorkspaceOrCommon(
81-
installable: Installable[],
138+
installableResult: ProjectInstallableResult,
82139
common: Installable[],
83140
showSkipOption: boolean,
84141
installed: string[],
85142
): Promise<PipPackages | undefined> {
143+
const installable = installableResult.installables;
86144
if (installable.length === 0 && common.length === 0) {
87145
return undefined;
88146
}
@@ -124,7 +182,20 @@ async function selectWorkspaceOrCommon(
124182
if (selected && !Array.isArray(selected)) {
125183
try {
126184
if (selected.label === PackageManagement.workspaceDependencies) {
127-
return await selectFromInstallableToInstall(installable, undefined, { showBackButton });
185+
const selectedInstallables = await selectFromInstallableToInstall(installable, undefined, {
186+
showBackButton,
187+
});
188+
189+
const validationError = installableResult.validationError;
190+
const shouldProceed = await shouldProceedAfterPyprojectValidation(
191+
validationError,
192+
selectedInstallables?.install ?? [],
193+
);
194+
if (!shouldProceed) {
195+
return undefined;
196+
}
197+
198+
return selectedInstallables;
128199
} else if (selected.label === PackageManagement.searchCommonPackages) {
129200
return await selectFromCommonPackagesToInstall(common, installed, undefined, { showBackButton });
130201
} else if (selected.label === PackageManagement.skipPackageInstallation) {
@@ -136,7 +207,7 @@ async function selectWorkspaceOrCommon(
136207
// eslint-disable-next-line @typescript-eslint/no-explicit-any
137208
} catch (ex: any) {
138209
if (ex === QuickInputButtons.Back) {
139-
return selectWorkspaceOrCommon(installable, common, showSkipOption, installed);
210+
return selectWorkspaceOrCommon(installableResult, common, showSkipOption, installed);
140211
}
141212
}
142213
}
@@ -148,32 +219,58 @@ export interface PipPackages {
148219
uninstall: string[];
149220
}
150221

222+
export interface ProjectInstallableResult {
223+
/**
224+
* List of installable packages from pyproject.toml file
225+
*/
226+
installables: Installable[];
227+
228+
/**
229+
* Validation error information if pyproject.toml validation failed
230+
*/
231+
validationError?: ValidationError;
232+
}
233+
234+
export interface ValidationError {
235+
/**
236+
* Human-readable error message describing the validation issue
237+
*/
238+
message: string;
239+
240+
/**
241+
* URI to the pyproject.toml file that has the validation error
242+
*/
243+
fileUri: Uri;
244+
}
245+
151246
export async function getWorkspacePackagesToInstall(
152247
api: PythonEnvironmentApi,
153248
options: PackageManagementOptions,
154249
project?: PythonProject[],
155250
environment?: PythonEnvironment,
156251
log?: LogOutputChannel,
157252
): Promise<PipPackages | undefined> {
158-
const installable = (await getProjectInstallable(api, project)) ?? [];
253+
const installableResult = await getProjectInstallable(api, project);
159254
let common = await getCommonPackages();
160255
let installed: string[] | undefined;
161256
if (environment) {
162257
installed = (await refreshPipPackages(environment, log, { showProgress: true }))?.map((pkg) => pkg.name);
163258
common = mergePackages(common, installed ?? []);
164259
}
165-
return selectWorkspaceOrCommon(installable, common, !!options.showSkipOption, installed ?? []);
260+
return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []);
166261
}
167262

168263
export async function getProjectInstallable(
169264
api: PythonEnvironmentApi,
170265
projects?: PythonProject[],
171-
): Promise<Installable[]> {
266+
): Promise<ProjectInstallableResult> {
172267
if (!projects) {
173-
return [];
268+
return { installables: [] };
174269
}
175270
const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**';
176271
const installable: Installable[] = [];
272+
let validationError: { message: string; fileUri: Uri } | undefined;
273+
177274
await withProgress(
178275
{
179276
location: ProgressLocation.Notification,
@@ -204,6 +301,18 @@ export async function getProjectInstallable(
204301
filtered.map(async (uri) => {
205302
if (uri.fsPath.endsWith('.toml')) {
206303
const toml = await tomlParse(uri.fsPath);
304+
305+
// Validate pyproject.toml
306+
if (!validationError) {
307+
const error = validatePyprojectToml(toml);
308+
if (error) {
309+
validationError = {
310+
message: error,
311+
fileUri: uri,
312+
};
313+
}
314+
}
315+
207316
installable.push(...getTomlInstallable(toml, uri));
208317
} else {
209318
const name = path.basename(uri.fsPath);
@@ -219,7 +328,49 @@ export async function getProjectInstallable(
219328
);
220329
},
221330
);
222-
return installable;
331+
332+
return {
333+
installables: installable,
334+
validationError,
335+
};
336+
}
337+
338+
export async function shouldProceedAfterPyprojectValidation(
339+
validationError: ValidationError | undefined,
340+
install: string[],
341+
): Promise<boolean> {
342+
// 1. If no validation error or no installables selected, proceed
343+
if (!validationError || install.length === 0) {
344+
return true;
345+
}
346+
347+
const selectedTomlInstallables = install.some((arg, index, arr) => arg === '-e' && index + 1 < arr.length);
348+
if (!selectedTomlInstallables) {
349+
// 2. If no toml installables selected, proceed
350+
return true;
351+
}
352+
353+
// 3. Otherwise, show error message and ask user what to do
354+
const openButton = { title: Pickers.pyProject.openFile };
355+
const continueButton = { title: Pickers.pyProject.continueAnyway };
356+
const cancelButton = { title: Pickers.pyProject.cancel, isCloseAffordance: true };
357+
358+
const selection = await window.showErrorMessage(
359+
validationError.message + Pickers.pyProject.validationErrorAction,
360+
openButton,
361+
continueButton,
362+
cancelButton,
363+
);
364+
365+
if (selection === continueButton) {
366+
return true;
367+
}
368+
369+
if (selection === openButton) {
370+
await window.showTextDocument(validationError.fileUri);
371+
}
372+
373+
return false;
223374
}
224375

225376
export function isPipInstallCommand(command: string): boolean {

src/managers/builtin/venvManager.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,7 @@ export class VenvManager implements EnvironmentManager {
232232
}
233233
} else if (result?.envCreationErr) {
234234
// Show error message to user when environment creation failed
235-
showErrorMessage(
236-
l10n.t('Failed to create virtual environment: {0}', result.envCreationErr),
237-
);
235+
showErrorMessage(l10n.t('Failed to create virtual environment: {0}', result.envCreationErr));
238236
}
239237
return result?.environment ?? undefined;
240238
} finally {

src/managers/builtin/venvStepBasedFlow.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { EventNames } from '../../common/telemetry/constants';
77
import { sendTelemetryEvent } from '../../common/telemetry/sender';
88
import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis';
99
import { NativePythonFinder } from '../common/nativePythonFinder';
10-
import { getProjectInstallable, getWorkspacePackagesToInstall, PipPackages } from './pipUtils';
10+
import {
11+
getProjectInstallable,
12+
getWorkspacePackagesToInstall,
13+
PipPackages,
14+
shouldProceedAfterPyprojectValidation,
15+
} from './pipUtils';
1116
import { CreateEnvironmentResult, createWithProgress, ensureGlobalEnv } from './venvUtils';
1217

1318
/**
@@ -335,12 +340,20 @@ export async function createStepBasedVenvFlow(
335340

336341
// Get workspace dependencies to install
337342
const project = api.getPythonProject(venvRoot);
338-
const installables = await getProjectInstallable(api, project ? [project] : undefined);
343+
const result = await getProjectInstallable(api, project ? [project] : undefined);
344+
const installables = result.installables;
339345
const allPackages = [];
340346
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
341347
if (options.additionalPackages) {
342348
allPackages.push(...options.additionalPackages);
343349
}
350+
351+
const validationError = result.validationError;
352+
const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages);
353+
if (!shouldProceed) {
354+
return undefined;
355+
}
356+
344357
return await createWithProgress(nativeFinder, api, log, manager, state.basePython, venvRoot, quickEnvPath, {
345358
install: allPackages,
346359
uninstall: [],

src/managers/builtin/venvUtils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
} from '../common/nativePythonFinder';
2727
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
2828
import { runPython, runUV, shouldUseUv } from './helpers';
29-
import { getProjectInstallable, PipPackages } from './pipUtils';
29+
import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils';
3030
import { resolveSystemPythonEnvironmentPath } from './utils';
3131
import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments';
3232
import { createStepBasedVenvFlow } from './venvStepBasedFlow';
@@ -396,13 +396,20 @@ export async function quickCreateVenv(
396396
const project = api.getPythonProject(venvRoot);
397397

398398
sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'quick' });
399-
const installables = await getProjectInstallable(api, project ? [project] : undefined);
399+
const result = await getProjectInstallable(api, project ? [project] : undefined);
400+
const installables = result.installables;
400401
const allPackages = [];
401402
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
402403
if (additionalPackages) {
403404
allPackages.push(...additionalPackages);
404405
}
405406

407+
const validationError = result.validationError;
408+
const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages);
409+
if (!shouldProceed) {
410+
return undefined;
411+
}
412+
406413
// Check if .venv already exists
407414
let venvPath = path.join(venvRoot.fsPath, '.venv');
408415
if (await fsapi.pathExists(venvPath)) {

0 commit comments

Comments
 (0)