Skip to content

Commit 62bfe63

Browse files
committed
add validatePyprojectToml
1 parent 0adb503 commit 62bfe63

5 files changed

Lines changed: 138 additions & 15 deletions

File tree

src/managers/builtin/pipUtils.ts

Lines changed: 128 additions & 5 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 } 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,88 @@ import { Installable } from '../common/types';
1313
import { mergePackages } from '../common/utils';
1414
import { refreshPipPackages } from './utils';
1515

16+
/**
17+
* Validates pyproject.toml fields according to PEP 508, PEP 440, PEP 621, PEP 517/518
18+
* Returns error message if invalid, undefined if valid
19+
*/
20+
function validatePyprojectToml(toml: tomljs.JsonMap, filePath: string): string | undefined {
21+
// 1. Validate package name (PEP 508)
22+
if (toml.project && (toml.project as tomljs.JsonMap).name) {
23+
const name = (toml.project as tomljs.JsonMap).name as string;
24+
// PEP 508 regex: must start and end with a letter or digit, can contain -_.
25+
const nameRegex = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$/;
26+
if (!nameRegex.test(name)) {
27+
return l10n.t(
28+
'Invalid package name "{0}" in {1}. Package names must start and end with a letter or digit and may only contain -, _, ., and alphanumeric characters. No spaces allowed. See PEP 508: https://peps.python.org/pep-0508/',
29+
name,
30+
path.basename(filePath),
31+
);
32+
}
33+
}
34+
35+
// 2. Validate version format (PEP 440)
36+
if (toml.project && (toml.project as tomljs.JsonMap).version) {
37+
const version = (toml.project as tomljs.JsonMap).version as string;
38+
// PEP 440 simplified regex
39+
const versionRegex =
40+
/^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$/;
41+
if (!versionRegex.test(version)) {
42+
return l10n.t(
43+
'Invalid version "{0}" in {1}. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3"). See https://peps.python.org/pep-0440/',
44+
version,
45+
path.basename(filePath),
46+
);
47+
}
48+
}
49+
50+
// 3. Validate required fields (PEP 621)
51+
if (toml.project) {
52+
const project = toml.project as tomljs.JsonMap;
53+
if (!project.name) {
54+
return l10n.t(
55+
'Missing required field "name" in [project] section of {0}. See PEP 621: https://peps.python.org/pep-0621/',
56+
path.basename(filePath),
57+
);
58+
}
59+
}
60+
61+
// 4. Validate build system (PEP 517/518)
62+
if (toml['build-system']) {
63+
const buildSystem = toml['build-system'] as tomljs.JsonMap;
64+
if (!buildSystem.requires) {
65+
return l10n.t(
66+
'Missing required field "requires" in [build-system] section of {0}. See PEP 517: https://peps.python.org/pep-0517/',
67+
path.basename(filePath),
68+
);
69+
}
70+
if (!buildSystem['build-backend']) {
71+
return l10n.t(
72+
'Missing required field "build-backend" in [build-system] section of {0}. See PEP 518: https://peps.python.org/pep-0518/',
73+
path.basename(filePath),
74+
);
75+
}
76+
}
77+
78+
// 5. Validate dependencies format (PEP 508)
79+
if (toml.project && (toml.project as tomljs.JsonMap).dependencies) {
80+
const deps = (toml.project as tomljs.JsonMap).dependencies as string[];
81+
if (Array.isArray(deps)) {
82+
for (const dep of deps) {
83+
// Basic check for common mistakes
84+
if (dep.includes(' ') || /\s{2,}/.test(dep)) {
85+
return l10n.t(
86+
'Invalid dependency "{0}" in {1}. Contains extra whitespace. See PEP 508: https://peps.python.org/pep-0508/',
87+
dep,
88+
path.basename(filePath),
89+
);
90+
}
91+
}
92+
}
93+
}
94+
95+
return undefined; // No errors
96+
}
97+
1698
async function tomlParse(fsPath: string, log?: LogOutputChannel): Promise<tomljs.JsonMap> {
1799
try {
18100
const content = await fse.readFile(fsPath, 'utf-8');
@@ -148,14 +230,37 @@ export interface PipPackages {
148230
uninstall: string[];
149231
}
150232

233+
export interface ProjectInstallableResult {
234+
/**
235+
* List of installable packages from requirements.txt and pyproject.toml files
236+
*/
237+
installables: Installable[];
238+
239+
/**
240+
* Validation error information if pyproject.toml validation failed
241+
*/
242+
validationError?: {
243+
/**
244+
* Human-readable error message describing the validation issue
245+
*/
246+
message: string;
247+
248+
/**
249+
* URI to the pyproject.toml file that has the validation error
250+
*/
251+
fileUri: Uri;
252+
};
253+
}
254+
151255
export async function getWorkspacePackagesToInstall(
152256
api: PythonEnvironmentApi,
153257
options: PackageManagementOptions,
154258
project?: PythonProject[],
155259
environment?: PythonEnvironment,
156260
log?: LogOutputChannel,
157261
): Promise<PipPackages | undefined> {
158-
const installable = (await getProjectInstallable(api, project)) ?? [];
262+
const result = await getProjectInstallable(api, project);
263+
const installable = result.installables;
159264
let common = await getCommonPackages();
160265
let installed: string[] | undefined;
161266
if (environment) {
@@ -168,12 +273,14 @@ export async function getWorkspacePackagesToInstall(
168273
export async function getProjectInstallable(
169274
api: PythonEnvironmentApi,
170275
projects?: PythonProject[],
171-
): Promise<Installable[]> {
276+
): Promise<ProjectInstallableResult> {
172277
if (!projects) {
173-
return [];
278+
return { installables: [] };
174279
}
175280
const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**';
176281
const installable: Installable[] = [];
282+
let validationError: { message: string; fileUri: Uri } | undefined;
283+
177284
await withProgress(
178285
{
179286
location: ProgressLocation.Notification,
@@ -204,6 +311,18 @@ export async function getProjectInstallable(
204311
filtered.map(async (uri) => {
205312
if (uri.fsPath.endsWith('.toml')) {
206313
const toml = await tomlParse(uri.fsPath);
314+
315+
// Validate pyproject.toml and capture first error only
316+
if (!validationError) {
317+
const error = validatePyprojectToml(toml, uri.fsPath);
318+
if (error) {
319+
validationError = {
320+
message: error,
321+
fileUri: uri,
322+
};
323+
}
324+
}
325+
207326
installable.push(...getTomlInstallable(toml, uri));
208327
} else {
209328
const name = path.basename(uri.fsPath);
@@ -219,7 +338,11 @@ export async function getProjectInstallable(
219338
);
220339
},
221340
);
222-
return installable;
341+
342+
return {
343+
installables: installable,
344+
validationError,
345+
};
223346
}
224347

225348
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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ export async function createStepBasedVenvFlow(
335335

336336
// Get workspace dependencies to install
337337
const project = api.getPythonProject(venvRoot);
338-
const installables = await getProjectInstallable(api, project ? [project] : undefined);
338+
const result = await getProjectInstallable(api, project ? [project] : undefined);
339+
const installables = result.installables;
339340
const allPackages = [];
340341
allPackages.push(...(installables?.flatMap((i) => i.args ?? []) ?? []));
341342
if (options.additionalPackages) {

src/managers/builtin/venvUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ 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) {

src/test/managers/builtin/pipUtils.unit.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ suite('Pip Utils - getProjectInstallable', () => {
7474
// Act: Call getProjectInstallable
7575
const workspacePath = Uri.file('/test/path/root').fsPath;
7676
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
77-
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
77+
const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables;
7878

7979
// Assert: Should find all three requirements files
8080
assert.strictEqual(result.length, 3, 'Should find three requirements files');
@@ -119,7 +119,7 @@ suite('Pip Utils - getProjectInstallable', () => {
119119
// Act: Call getProjectInstallable
120120
const workspacePath = Uri.file('/test/path/root').fsPath;
121121
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
122-
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
122+
const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables;
123123

124124
// Assert: Should deduplicate and only have 2 unique files
125125
assert.strictEqual(result.length, 2, 'Should deduplicate and have 2 unique files');
@@ -149,7 +149,7 @@ suite('Pip Utils - getProjectInstallable', () => {
149149
// Act: Call getProjectInstallable
150150
const workspacePath = Uri.file('/test/path/root').fsPath;
151151
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
152-
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
152+
const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables;
153153

154154
// Assert: Should find all files
155155
assert.strictEqual(result.length, 3, 'Should find three files');
@@ -164,7 +164,7 @@ suite('Pip Utils - getProjectInstallable', () => {
164164

165165
test('should return empty array when no projects provided', async () => {
166166
// Act: Call with no projects
167-
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, undefined);
167+
const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, undefined)).installables;
168168

169169
// Assert: Should return empty array
170170
assert.strictEqual(result.length, 0, 'Should return empty array');
@@ -189,7 +189,7 @@ suite('Pip Utils - getProjectInstallable', () => {
189189
// Act: Call with only workspace project
190190
const workspacePath = Uri.file('/test/path/root').fsPath;
191191
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
192-
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
192+
const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables;
193193

194194
// Assert: Should only include files from workspace
195195
assert.strictEqual(result.length, 1, 'Should only include files from project directory');

0 commit comments

Comments
 (0)