diff --git a/src/common/localize.ts b/src/common/localize.ts index 2077430b..fd9a8843 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -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 { diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index d221f5a8..9fd540ab 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -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'; @@ -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 { try { const content = await fse.readFile(fsPath, 'utf-8'); @@ -78,11 +135,12 @@ async function getCommonPackages(): Promise { } async function selectWorkspaceOrCommon( - installable: Installable[], + installableResult: ProjectInstallableResult, common: Installable[], showSkipOption: boolean, installed: string[], ): Promise { + const installable = installableResult.installables; if (installable.length === 0 && common.length === 0) { return undefined; } @@ -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) { @@ -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); } } } @@ -148,6 +219,30 @@ 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, @@ -155,25 +250,27 @@ export async function getWorkspacePackagesToInstall( environment?: PythonEnvironment, log?: LogOutputChannel, ): Promise { - 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 { +): Promise { 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, @@ -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); @@ -219,7 +328,49 @@ export async function getProjectInstallable( ); }, ); - return installable; + + return { + installables: installable, + validationError, + }; +} + +export async function shouldProceedAfterPyprojectValidation( + validationError: ValidationError | undefined, + install: string[], +): Promise { + // 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 { diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index f0a2af14..e042cc11 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -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 { diff --git a/src/managers/builtin/venvStepBasedFlow.ts b/src/managers/builtin/venvStepBasedFlow.ts index beb7c1d2..2e8e7ded 100644 --- a/src/managers/builtin/venvStepBasedFlow.ts +++ b/src/managers/builtin/venvStepBasedFlow.ts @@ -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'; /** @@ -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: [], diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 6ee09d21..9c778317 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -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'; @@ -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)) { diff --git a/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts b/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts new file mode 100644 index 00000000..579c3c74 --- /dev/null +++ b/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts @@ -0,0 +1,471 @@ +import assert from 'assert'; +import { Uri } from 'vscode'; +import { + PyprojectToml, + shouldProceedAfterPyprojectValidation, + validatePyprojectToml, + ValidationError, +} from '../../../managers/builtin/pipUtils'; + +suite('pipUtils - validatePyproject', () => { + suite('shouldProceedAfterPyprojectValidation', () => { + const mockValidationError: ValidationError = { + message: 'Invalid package name "my package" in pyproject.toml.', + fileUri: Uri.file('/test/path/pyproject.toml'), + }; + + test('should return true when no validation error exists', async () => { + // Arrange: no validation error + const validationError = undefined; + const install = ['-e', '/test/path']; + + // Act + const result = await shouldProceedAfterPyprojectValidation(validationError, install); + + // Assert + assert.strictEqual(result, true, 'Should proceed when no validation error'); + }); + + test('should return true when install array is empty', async () => { + // Arrange: validation error exists but no packages selected + const install: string[] = []; + + // Act + const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + + // Assert + assert.strictEqual(result, true, 'Should proceed when no packages selected'); + }); + + test('should return true when only requirements.txt packages selected (no -e flag)', async () => { + // Arrange: validation error exists but only requirements.txt packages selected + const install = ['-r', '/test/requirements.txt']; + + // Act + const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + + // Assert + assert.strictEqual(result, true, 'Should proceed when no TOML packages selected'); + }); + + test('should return true when only PyPI packages selected (no flags at all)', async () => { + // Arrange: only PyPI package names, no flags + const install = ['numpy', 'pandas', 'requests']; + + // Act + const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + + // Assert + assert.strictEqual(result, true, 'Should proceed when only PyPI packages selected'); + }); + + test('should not trigger on -e flag at end of array without following argument', async () => { + // Arrange: -e flag is last item (malformed, but should not crash) + const install = ['numpy', '-e']; + // This is edge case - -e at end means no path follows, so index + 1 < arr.length is false + + // Act + const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + + // Assert + assert.strictEqual(result, true, 'Should not crash on malformed -e flag at end'); + }); + }); + + function verifyValidationError(toml: PyprojectToml, expectedError: string | undefined) { + const ActualError = validatePyprojectToml(toml); + assert.strictEqual(ActualError, expectedError); + } + + suite('validatePyprojectToml - Package Name Validation (PEP 508)', () => { + test('should accept valid single-character package name', () => { + const toml: PyprojectToml = { + project: { name: 'a' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept valid package name with letters and numbers', () => { + const toml: PyprojectToml = { + project: { name: 'mypackage123' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept valid package name with hyphens', () => { + const toml: PyprojectToml = { + project: { name: 'my-package' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept valid package name with underscores', () => { + const toml: PyprojectToml = { + project: { name: 'my_package' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept valid package name with dots', () => { + const toml: PyprojectToml = { + project: { name: 'my.package' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept valid package name with mixed separators', () => { + const toml: PyprojectToml = { + project: { name: 'my-package_name.v2' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept complex valid package name', () => { + const toml: PyprojectToml = { + project: { name: 'Django-REST-framework' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should reject package name with spaces', () => { + const toml: PyprojectToml = { + project: { name: 'my package' }, + }; + verifyValidationError(toml, 'Invalid package name "my package" in pyproject.toml.'); + }); + + test('should reject package name starting with hyphen', () => { + const toml: PyprojectToml = { + project: { name: '-mypackage' }, + }; + verifyValidationError(toml, 'Invalid package name "-mypackage" in pyproject.toml.'); + }); + + test('should reject package name ending with hyphen', () => { + const toml: PyprojectToml = { + project: { name: 'mypackage-' }, + }; + verifyValidationError(toml, 'Invalid package name "mypackage-" in pyproject.toml.'); + }); + + test('should reject package name starting with dot', () => { + const toml: PyprojectToml = { + project: { name: '.mypackage' }, + }; + verifyValidationError(toml, 'Invalid package name ".mypackage" in pyproject.toml.'); + }); + + test('should reject package name ending with dot', () => { + const toml: PyprojectToml = { + project: { name: 'mypackage.' }, + }; + verifyValidationError(toml, 'Invalid package name "mypackage." in pyproject.toml.'); + }); + + test('should reject package name starting with underscore', () => { + const toml: PyprojectToml = { + project: { name: '_mypackage' }, + }; + verifyValidationError(toml, 'Invalid package name "_mypackage" in pyproject.toml.'); + }); + + test('should reject package name ending with underscore', () => { + const toml: PyprojectToml = { + project: { name: 'mypackage_' }, + }; + verifyValidationError(toml, 'Invalid package name "mypackage_" in pyproject.toml.'); + }); + + test('should reject package name with special characters', () => { + const toml: PyprojectToml = { + project: { name: 'my@package' }, + }; + verifyValidationError(toml, 'Invalid package name "my@package" in pyproject.toml.'); + }); + + test('should reject package name with only separator', () => { + const toml: PyprojectToml = { + project: { name: '-' }, + }; + verifyValidationError(toml, 'Invalid package name "-" in pyproject.toml.'); + }); + + test('should accept when no project section exists', () => { + const toml: PyprojectToml = {}; + verifyValidationError(toml, undefined); + }); + }); + + suite('validatePyprojectToml - Required Fields (PEP 621)', () => { + test('should accept valid project with name', () => { + const toml: PyprojectToml = { + project: { name: 'test' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should reject project without name field', () => { + const toml: PyprojectToml = { + project: { version: '1.0.0' }, + }; + verifyValidationError(toml, 'Missing required field "name" in [project] section of pyproject.toml.'); + }); + + test('should accept when no project section exists', () => { + const toml: PyprojectToml = {}; + verifyValidationError(toml, undefined); + }); + }); + + suite('validatePyprojectToml - Build System (PEP 518)', () => { + test('should accept valid build-system with requires', () => { + const toml: PyprojectToml = { + project: { name: 'test' }, + 'build-system': { + requires: ['setuptools', 'wheel'], + }, + }; + verifyValidationError(toml, undefined); + }); + + test('should reject build-system without requires field', () => { + const toml: PyprojectToml = { + project: { name: 'test' }, + 'build-system': {}, + }; + verifyValidationError( + toml, + 'Missing required field "requires" in [build-system] section of pyproject.toml.', + ); + }); + + test('should accept when no build-system section exists', () => { + const toml: PyprojectToml = { + project: { name: 'test' }, + }; + verifyValidationError(toml, undefined); + }); + }); + + suite('validatePyprojectToml - Version Validation (PEP 440)', () => { + interface VersionTestCase { + version: string; + expectedError: string | undefined; + description: string; + } + + function createVersionToml(version: string): PyprojectToml { + return { + project: { name: 'test', version }, + }; + } + + const versionTestCases: VersionTestCase[] = [ + // Basic release versions + { version: '1.0', expectedError: undefined, description: 'simple version 1.0' }, + { version: '1.0.0', expectedError: undefined, description: 'version with three parts 1.0.0' }, + { version: '1.2.3.4.5', expectedError: undefined, description: 'version with many parts 1.2.3.4.5' }, + { version: '1.0.01', expectedError: undefined, description: 'version with leading zeros 1.0.01' }, + { version: '0', expectedError: undefined, description: 'single digit version 0' }, + { version: '2024.1.15', expectedError: undefined, description: 'large version numbers 2024.1.15' }, + + // Epoch versions + { version: '1!1.0', expectedError: undefined, description: 'epoch version 1!1.0' }, + { version: '2!0.0.1', expectedError: undefined, description: 'epoch version 2!0.0.1' }, + { version: '100!1.0.0', expectedError: undefined, description: 'large epoch 100!1.0.0' }, + + // Pre-release versions - Alpha + { version: '1.0a1', expectedError: undefined, description: 'alpha version 1.0a1' }, + { version: '1.0.a1', expectedError: undefined, description: 'alpha with dot separator 1.0.a1' }, + { version: '1.0-a1', expectedError: undefined, description: 'alpha with hyphen separator 1.0-a1' }, + { version: '1.0_a1', expectedError: undefined, description: 'alpha with underscore 1.0_a1' }, + { version: '1.0a', expectedError: undefined, description: 'alpha without number 1.0a' }, + { version: '1.0alpha1', expectedError: undefined, description: 'long form alpha 1.0alpha1' }, + { version: '1.0.alpha.1', expectedError: undefined, description: 'alpha with separators 1.0.alpha.1' }, + { version: '1.0a999', expectedError: undefined, description: 'alpha with large number 1.0a999' }, + + // Pre-release versions - Beta + { version: '1.0b1', expectedError: undefined, description: 'beta version 1.0b1' }, + { version: '1.0beta1', expectedError: undefined, description: 'long form beta 1.0beta1' }, + { version: '1.0.beta.2', expectedError: undefined, description: 'beta with separators 1.0.beta.2' }, + { version: '1.0b', expectedError: undefined, description: 'beta without number 1.0b' }, + + // Pre-release versions - RC + { version: '1.0rc1', expectedError: undefined, description: 'rc version 1.0rc1' }, + { version: '1.0c1', expectedError: undefined, description: 'c version 1.0c1' }, + { version: '1.0.rc.3', expectedError: undefined, description: 'rc with separators 1.0.rc.3' }, + { version: '1.0rc', expectedError: undefined, description: 'rc without number 1.0rc' }, + + // Pre-release versions - Other + { version: '1.0preview1', expectedError: undefined, description: 'preview version 1.0preview1' }, + { version: '1.0pre1', expectedError: undefined, description: 'pre version 1.0pre1' }, + { + version: '1.0-preview-2', + expectedError: undefined, + description: 'preview with separators 1.0-preview-2', + }, + + // Post-release versions + { version: '1.0.post1', expectedError: undefined, description: 'post version 1.0.post1' }, + { version: '1.0post1', expectedError: undefined, description: 'post without dot 1.0post1' }, + { version: '1.0-post1', expectedError: undefined, description: 'post with hyphen 1.0-post1' }, + { version: '1.0_post1', expectedError: undefined, description: 'post with underscore 1.0_post1' }, + { version: '1.0.post', expectedError: undefined, description: 'post without number 1.0.post' }, + { version: '1.0-1', expectedError: undefined, description: 'implicit post version 1.0-1' }, + { version: '1.0-5', expectedError: undefined, description: 'implicit post version 1.0-5' }, + { version: '1.0rev1', expectedError: undefined, description: 'rev version 1.0rev1' }, + { version: '1.0r1', expectedError: undefined, description: 'r version 1.0r1' }, + { version: '1.0.rev.2', expectedError: undefined, description: 'rev with separators 1.0.rev.2' }, + { version: '1.0.post999', expectedError: undefined, description: 'post with large number 1.0.post999' }, + + // Dev versions + { version: '1.0.dev1', expectedError: undefined, description: 'dev version 1.0.dev1' }, + { version: '1.0dev1', expectedError: undefined, description: 'dev without dot 1.0dev1' }, + { version: '1.0-dev1', expectedError: undefined, description: 'dev with hyphen 1.0-dev1' }, + { version: '1.0_dev1', expectedError: undefined, description: 'dev with underscore 1.0_dev1' }, + { version: '1.0.dev', expectedError: undefined, description: 'dev without number 1.0.dev' }, + { version: '1.0.dev999', expectedError: undefined, description: 'dev with large number 1.0.dev999' }, + + // Local versions + { version: '1.0+abc', expectedError: undefined, description: 'local version 1.0+abc' }, + { version: '1.0+abc.def', expectedError: undefined, description: 'local with dots 1.0+abc.def' }, + { version: '1.0+abc-def', expectedError: undefined, description: 'local with hyphens 1.0+abc-def' }, + { version: '1.0+abc_def', expectedError: undefined, description: 'local with underscores 1.0+abc_def' }, + { version: '1.0+abc.5', expectedError: undefined, description: 'local with numbers 1.0+abc.5' }, + { + version: '1.0+abc.def-ghi_jkl', + expectedError: undefined, + description: 'local with mixed separators 1.0+abc.def-ghi_jkl', + }, + { version: '1.0+001', expectedError: undefined, description: 'numeric local version 1.0+001' }, + { version: '1.0+g1234567', expectedError: undefined, description: 'git hash-like local 1.0+g1234567' }, + + // Combined versions + { version: '1.0a1.post1', expectedError: undefined, description: 'pre + post 1.0a1.post1' }, + { + version: '1.0a1.post1.dev2', + expectedError: undefined, + description: 'pre + post + dev 1.0a1.post1.dev2', + }, + { version: '1.0.post1.dev2', expectedError: undefined, description: 'post + dev 1.0.post1.dev2' }, + { version: '1.0a1.dev1', expectedError: undefined, description: 'pre + dev 1.0a1.dev1' }, + { version: '1.0a1+local', expectedError: undefined, description: 'pre + local 1.0a1+local' }, + { version: '1.0.post1+local', expectedError: undefined, description: 'post + local 1.0.post1+local' }, + { version: '1.0.dev1+local', expectedError: undefined, description: 'dev + local 1.0.dev1+local' }, + { + version: '1!1.0a1.post1.dev2+abc', + expectedError: undefined, + description: 'epoch + all components 1!1.0a1.post1.dev2+abc', + }, + { + version: '2!1.2.3rc4.post5.dev6+local.version', + expectedError: undefined, + description: 'full complex version 2!1.2.3rc4.post5.dev6+local.version', + }, + { version: '1.0rc1-1', expectedError: undefined, description: 'rc + implicit post 1.0rc1-1' }, + + // Version with v prefix + { version: 'v1.0', expectedError: undefined, description: 'version with v prefix v1.0' }, + { version: 'v1.0.0', expectedError: undefined, description: 'version with v prefix v1.0.0' }, + { version: 'v1.0a1', expectedError: undefined, description: 'v prefix with pre-release v1.0a1' }, + { version: 'v1.0-1', expectedError: undefined, description: 'v prefix with implicit post v1.0-1' }, + { + version: 'v1!2.0rc1.post2.dev3+local', + expectedError: undefined, + description: 'v prefix with all components v1!2.0rc1.post2.dev3+local', + }, + + // Case insensitivity + { version: '1.0A1', expectedError: undefined, description: 'uppercase alpha 1.0A1' }, + { version: '1.0ALPHA1', expectedError: undefined, description: 'uppercase ALPHA 1.0ALPHA1' }, + { version: '1.0Alpha1', expectedError: undefined, description: 'mixed case Alpha 1.0Alpha1' }, + { version: '1.0POST1', expectedError: undefined, description: 'uppercase POST 1.0POST1' }, + { version: '1.0DEV1', expectedError: undefined, description: 'uppercase DEV 1.0DEV1' }, + { version: '1.0RC1', expectedError: undefined, description: 'uppercase RC 1.0RC1' }, + { version: 'V1.0', expectedError: undefined, description: 'uppercase V prefix V1.0' }, + { + version: '1.0Alpha1.POST2.Dev3', + expectedError: undefined, + description: 'mixed case components 1.0Alpha1.POST2.Dev3', + }, + + // Invalid versions + { + version: '.1.0', + expectedError: 'Invalid version ".1.0" in pyproject.toml.', + description: 'starting with dot .1.0', + }, + { + version: '1.0.', + expectedError: 'Invalid version "1.0." in pyproject.toml.', + description: 'ending with dot 1.0.', + }, + { + version: 'abc', + expectedError: 'Invalid version "abc" in pyproject.toml.', + description: 'completely invalid abc', + }, + { version: '', expectedError: 'Version cannot be empty in pyproject.toml.', description: 'empty version' }, + { + version: '1..0', + expectedError: 'Invalid version "1..0" in pyproject.toml.', + description: 'double dots 1..0', + }, + { + version: '1.0 rc1', + expectedError: 'Invalid version "1.0 rc1" in pyproject.toml.', + description: 'spaces 1.0 rc1', + }, + { + version: '1.0gamma1', + expectedError: 'Invalid version "1.0gamma1" in pyproject.toml.', + description: 'invalid pre-release keyword 1.0gamma1', + }, + { + version: '1 1.0', + expectedError: 'Invalid version "1 1.0" in pyproject.toml.', + description: 'epoch without exclamation 1 1.0', + }, + { + version: '1.0local', + expectedError: 'Invalid version "1.0local" in pyproject.toml.', + description: 'local without plus 1.0local', + }, + { + version: '1.0+abc+def', + expectedError: 'Invalid version "1.0+abc+def" in pyproject.toml.', + description: 'multiple local markers 1.0+abc+def', + }, + { + version: '1!', + expectedError: 'Invalid version "1!" in pyproject.toml.', + description: 'only epoch 1!', + }, + { + version: '1.0--a1', + expectedError: 'Invalid version "1.0--a1" in pyproject.toml.', + description: 'invalid separator combinations 1.0--a1', + }, + ]; + + versionTestCases.forEach(({ version, expectedError, description }) => { + test(`should ${expectedError ? 'reject' : 'accept'} ${description}`, () => { + const toml = createVersionToml(version); + verifyValidationError(toml, expectedError); + }); + }); + + // Edge cases + test('should accept when no version field exists', () => { + const toml: PyprojectToml = { + project: { name: 'test' }, + }; + verifyValidationError(toml, undefined); + }); + + test('should accept when no project section exists', () => { + const toml: PyprojectToml = {}; + verifyValidationError(toml, undefined); + }); + }); +}); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 076596e8..5ae58126 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -74,7 +74,7 @@ suite('Pip Utils - getProjectInstallable', () => { // Act: Call getProjectInstallable const workspacePath = Uri.file('/test/path/root').fsPath; const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }]; - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects); + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables; // Assert: Should find all three requirements files assert.strictEqual(result.length, 3, 'Should find three requirements files'); @@ -119,7 +119,7 @@ suite('Pip Utils - getProjectInstallable', () => { // Act: Call getProjectInstallable const workspacePath = Uri.file('/test/path/root').fsPath; const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }]; - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects); + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables; // Assert: Should deduplicate and only have 2 unique files assert.strictEqual(result.length, 2, 'Should deduplicate and have 2 unique files'); @@ -149,7 +149,7 @@ suite('Pip Utils - getProjectInstallable', () => { // Act: Call getProjectInstallable const workspacePath = Uri.file('/test/path/root').fsPath; const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }]; - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects); + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables; // Assert: Should find all files assert.strictEqual(result.length, 3, 'Should find three files'); @@ -164,7 +164,7 @@ suite('Pip Utils - getProjectInstallable', () => { test('should return empty array when no projects provided', async () => { // Act: Call with no projects - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, undefined); + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, undefined)).installables; // Assert: Should return empty array assert.strictEqual(result.length, 0, 'Should return empty array'); @@ -189,7 +189,7 @@ suite('Pip Utils - getProjectInstallable', () => { // Act: Call with only workspace project const workspacePath = Uri.file('/test/path/root').fsPath; const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }]; - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects); + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects)).installables; // Assert: Should only include files from workspace assert.strictEqual(result.length, 1, 'Should only include files from project directory');