diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index f6bdd8a7..2220a4e6 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -10,14 +10,13 @@ import { 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'; -import { traceInfo } from '../../common/logging'; +import { traceInfo, traceWarn } from '../../common/logging'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; -import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; +import { showErrorMessage, showQuickPickWithButtons, showTextDocument, withProgress } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; import { throwIfCancelled } from '../../internal/pythonToolSupport'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -28,46 +27,73 @@ import { createPipOrUvCommand } from './commands/factory'; import { PipListCommand, UvListCommand } from './commands/index'; export interface PyprojectToml { - project?: { - name?: string; - version?: string; - }; - 'build-system'?: { - requires?: unknown; - }; + project?: unknown; + 'build-system'?: unknown; +} + +function isTomlTable(value: unknown): value is tomljs.JsonMap { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isValidPep508Name(value: string): boolean { + return /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$/.test(value); } +/** + * Validates the pyproject fields consumed when installing project dependencies. + * Unknown fields are intentionally ignored so this remains a focused preflight, + * not a complete pyproject.toml standards validator. + */ 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 buildSystemValue = toml['build-system']; + if (buildSystemValue !== undefined) { + if (!isTomlTable(buildSystemValue)) { + return l10n.t('The [build-system] section must be a table in pyproject.toml.'); + } + if (buildSystemValue.requires === undefined) { + return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.'); + } + if (!isStringArray(buildSystemValue.requires)) { + return l10n.t( + 'The "requires" field in [build-system] must be an array of strings in pyproject.toml.', + ); + } } - const project = toml.project; - if (!project) { + const projectValue = toml.project; + if (projectValue === undefined) { return undefined; } + if (!isTomlTable(projectValue)) { + return l10n.t('The [project] section must be a table in pyproject.toml.'); + } - 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) { + const name = projectValue.name; + if (name === undefined || name === '') { return l10n.t('Missing required field "name" in [project] section of pyproject.toml.'); } + if (typeof name !== 'string') { + return l10n.t('The "name" field in [project] must be a string in 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)) { + // PEP 508 names must start and end with a letter or digit and may contain -_., in between. + if (!isValidPep508Name(name)) { return l10n.t('Invalid package name "{0}" in pyproject.toml.', name); } - // 4. Validate version format (PEP 440) - const version = project.version; + const version = projectValue.version; if (version !== undefined) { + if (typeof version !== 'string') { + return l10n.t('The "version" field in [project] must be a string in pyproject.toml.'); + } if (version.length === 0) { return l10n.t('Version cannot be empty in pyproject.toml.'); } @@ -76,35 +102,91 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { } } + const dependencies = projectValue.dependencies; + if (dependencies !== undefined && !isStringArray(dependencies)) { + return l10n.t('The "dependencies" field in [project] must be an array of strings in pyproject.toml.'); + } + + const optionalDependencies = projectValue['optional-dependencies']; + if (optionalDependencies !== undefined) { + if (!isTomlTable(optionalDependencies)) { + return l10n.t( + 'The "optional-dependencies" field in [project] must be a table of arrays of strings in pyproject.toml.', + ); + } + for (const [group, groupDependencies] of Object.entries(optionalDependencies)) { + if (!isValidPep508Name(group)) { + return l10n.t('Invalid optional dependency group name "{0}" in pyproject.toml.', group); + } + if (!isStringArray(groupDependencies)) { + return l10n.t( + 'Dependency group "{0}" in [project.optional-dependencies] must be an array of strings in pyproject.toml.', + group, + ); + } + } + } + return undefined; } -async function tomlParse(fsPath: string, log?: LogOutputChannel, strict = false): Promise { +interface TomlParseSuccess { + toml: tomljs.JsonMap; +} + +interface TomlParseFailure { + error: string; +} + +type TomlParseResult = TomlParseSuccess | TomlParseFailure; + +function conciseError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.split(/\r?\n/, 1)[0].trim() || l10n.t('Unknown error'); +} + +async function tomlParse(fsPath: string): Promise { try { const content = await fse.readFile(fsPath, 'utf-8'); - return tomljs.parse(content); - } catch (err) { - if (strict) { - throw new Error( - l10n.t('Could not read or parse {0}: {1}', fsPath, err instanceof Error ? err.message : String(err)), - ); - } - log?.error('Failed to parse `pyproject.toml`:', err); + return { toml: tomljs.parse(content) }; + } catch (error) { + traceWarn(`Failed to read or parse pyproject.toml at ${fsPath}:`, error); + return { + error: l10n.t('Could not read or parse pyproject.toml: {0}', conciseError(error)), + }; } - return {}; } function isPipInstallableToml(toml: tomljs.JsonMap): boolean { return toml['build-system'] !== undefined && toml.project !== undefined; } +function isPotentiallyInstallableToml(toml: tomljs.JsonMap): boolean { + if (isPipInstallableToml(toml)) { + return true; + } + const project = isTomlTable(toml.project) ? toml.project : undefined; + return project?.['optional-dependencies'] !== undefined; +} + +function isTomlRelevantToPreferredRoot(tomlUri: Uri, preferredRoot: Uri | undefined): boolean { + if (!preferredRoot) { + return true; + } + const tomlDirectory = path.dirname(tomlUri.fsPath); + return ( + isSameOrParentPath(preferredRoot.fsPath, tomlDirectory) || + isSameOrParentPath(tomlDirectory, preferredRoot.fsPath) + ); +} + /** * Extracts the declared package name from a parsed `pyproject.toml` (the * `[project].name` field, PEP 621). Returns `undefined` when the file does not * declare a name. */ function getTomlProjectName(toml: tomljs.JsonMap): string | undefined { - const project = toml.project as tomljs.JsonMap | undefined; + const project = isTomlTable(toml.project) ? toml.project : undefined; const name = project?.name; return typeof name === 'string' && name.length > 0 ? name : undefined; } @@ -125,14 +207,14 @@ function getTomlInstallable(toml: tomljs.JsonMap, tomlPath: Uri): Installable[] }); } - if (toml.project && (toml.project as tomljs.JsonMap)['optional-dependencies']) { - const deps = (toml.project as tomljs.JsonMap)['optional-dependencies']; - for (const key of Object.keys(deps)) { + const project = isTomlTable(toml.project) ? toml.project : undefined; + const optionalDependencies = project?.['optional-dependencies']; + if (isTomlTable(optionalDependencies)) { + for (const key of Object.keys(optionalDependencies)) { extras.push({ name: key, displayName: key, group: 'TOML', - // Use a single -e argument with the extras specified as part of the path args: ['-e', `${projectDir}[${key}]`], uri: tomlPath, }); @@ -166,12 +248,12 @@ async function selectWorkspaceOrCommon( installed: string[], ): Promise { const installable = installableResult.installables; - if (installable.length === 0 && common.length === 0) { + if (installable.length === 0 && common.length === 0 && installableResult.validationErrors.length === 0) { return undefined; } const items: QuickPickItem[] = []; - if (installable.length > 0) { + if (installable.length > 0 || installableResult.validationErrors.length > 0) { items.push({ label: PackageManagement.workspaceDependencies, description: PackageManagement.workspaceDependenciesDescription, @@ -207,20 +289,26 @@ async function selectWorkspaceOrCommon( if (selected && !Array.isArray(selected)) { try { if (selected.label === PackageManagement.workspaceDependencies) { - const selectedInstallables = await selectFromInstallableToInstall(installable, undefined, { - showBackButton, - }); + const selectedInstallables = + installable.length > 0 + ? await selectFromInstallableToInstall(installable, undefined, { showBackButton }) + : { install: [], uninstall: [] }; + if (!selectedInstallables) { + return undefined; + } - const validationError = installableResult.validationError; const shouldProceed = await shouldProceedAfterPyprojectValidation( - validationError, - selectedInstallables?.install ?? [], + installableResult.validationErrors, + { + allPyprojectTomlsSelected: installable.length === 0, + selectedPyprojectTomls: selectedInstallables.selectedUris, + }, ); if (!shouldProceed) { return undefined; } - return selectedInstallables; + return { install: selectedInstallables.install, uninstall: selectedInstallables.uninstall }; } else if (selected.label === PackageManagement.searchCommonPackages) { return await selectFromCommonPackagesToInstall(common, installed, undefined, { showBackButton }); } else if (selected.label === PackageManagement.skipPackageInstallation) { @@ -250,9 +338,9 @@ export interface ProjectInstallableResult { installables: Installable[]; /** - * Validation error information if pyproject.toml validation failed + * Validation errors from pyproject.toml files that could provide project dependencies. */ - validationError?: ValidationError; + validationErrors: ValidationError[]; } export interface ValidationError { @@ -320,11 +408,11 @@ export async function getProjectInstallable( options?: ProjectInstallableOptions, ): Promise { if (!projects) { - return { installables: [] }; + return { installables: [], validationErrors: [] }; } const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**'; const installable: Installable[] = []; - let validationError: { message: string; fileUri: Uri } | undefined; + const validationErrors: ValidationError[] = []; const search = async (token?: CancellationToken) => { throwIfCancelled(options?.token); @@ -337,22 +425,18 @@ export async function getProjectInstallable( ]) ).flat(); - // Deduplicate by fsPath const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values()); - - const fsPaths = projects.map((p) => p.uri.fsPath); - // Compute depth relative to the owning project root so ordering reflects - // "shallower within the project", independent of where the project lives on disk. + const fsPaths = projects.map((project) => project.uri.fsPath); const depthFromProject = (uri: Uri): number => { const projectRoot = api.getPythonProject(uri)?.uri.fsPath; if (!projectRoot) { return Number.MAX_SAFE_INTEGER; } - const rel = path.relative(projectRoot, uri.fsPath); - if (!rel) { + const relative = path.relative(projectRoot, uri.fsPath); + if (!relative) { return 0; } - return rel.split(path.sep).filter((segment) => segment.length > 0).length; + return relative.split(path.sep).filter((segment) => segment.length > 0).length; }; const filtered = uniqueResults .filter((uri) => { @@ -363,12 +447,10 @@ export async function getProjectInstallable( ) { return false; } - const p = api.getPythonProject(uri)?.uri.fsPath; - return p && fsPaths.includes(p); + const projectRoot = api.getPythonProject(uri)?.uri.fsPath; + return projectRoot && fsPaths.includes(projectRoot); }) .sort((a, b) => { - // Sort by path depth relative to the project root (shallowest first) so - // top-level files like requirements.txt appear before deeply nested ones. const depthA = depthFromProject(a); const depthB = depthFromProject(b); if (depthA !== depthB) { @@ -379,19 +461,34 @@ export async function getProjectInstallable( return pathA < pathB ? -1 : pathA > pathB ? 1 : 0; }); - // Parse all TOML files first (in parallel), keyed by fsPath, so the - // subsequent duplicate-detection pass can run sequentially and - // deterministically over the depth-sorted `filtered` list. - const parsedTomls = new Map(); + const parsedTomls = new Map(); await Promise.all( filtered.map(async (uri) => { if (uri.fsPath.endsWith('.toml')) { - const toml = await tomlParse(uri.fsPath, undefined, options?.runHeadless); - parsedTomls.set(uri.fsPath, toml); + parsedTomls.set(uri.fsPath, await tomlParse(uri.fsPath)); } }), ); + const tomlValidationErrors = new Map(); + for (const uri of filtered) { + const parsed = parsedTomls.get(uri.fsPath); + if (!parsed || 'error' in parsed) { + continue; + } + const error = validatePyprojectToml(parsed.toml); + if ( + error && + (getTomlInstallable(parsed.toml, uri).length > 0 || isPotentiallyInstallableToml(parsed.toml)) + ) { + tomlValidationErrors.set(uri.fsPath, error); + } + } + + const shouldSkipDisjointToml = (uri: Uri): boolean => + !!options?.deduplicateProjectPackages && + !isTomlRelevantToPreferredRoot(uri, options.preferredRoot); + const selectedTomls = new Map(); const ambiguousProjectNames = new Set(); if (options?.deduplicateProjectPackages) { @@ -400,9 +497,14 @@ export async function getProjectInstallable( if (!uri.fsPath.endsWith('.toml')) { continue; } - const toml = parsedTomls.get(uri.fsPath) ?? {}; - const projectName = getTomlProjectName(toml); - if (!projectName || getTomlInstallable(toml, uri).length === 0) { + const parsed = parsedTomls.get(uri.fsPath); + if (!parsed || 'error' in parsed || shouldSkipDisjointToml(uri)) { + continue; + } + const projectName = getTomlProjectName(parsed.toml); + const canProvideProjectPackage = + getTomlInstallable(parsed.toml, uri).length > 0 || tomlValidationErrors.has(uri.fsPath); + if (!projectName || !canProvideProjectPackage) { continue; } const normalizedName = normalizePackageName(projectName); @@ -447,11 +549,31 @@ export async function getProjectInstallable( for (const uri of filtered) { if (uri.fsPath.endsWith('.toml')) { - const toml = parsedTomls.get(uri.fsPath) ?? {}; - const tomlInstallables = getTomlInstallable(toml, uri); + const parsed = parsedTomls.get(uri.fsPath); + if (!parsed) { + continue; + } + if ('error' in parsed) { + if (isTomlRelevantToPreferredRoot(uri, options?.preferredRoot)) { + validationErrors.push({ message: parsed.error, fileUri: uri }); + } else { + traceInfo( + `Ignoring invalid pyproject.toml outside the environment creation root: ${uri.fsPath}`, + ); + } + continue; + } + + const validationError = tomlValidationErrors.get(uri.fsPath); + if (shouldSkipDisjointToml(uri)) { + traceInfo(`Skipping pyproject.toml outside the environment creation root: ${uri.fsPath}`); + continue; + } + const toml = parsed.toml; + const tomlInstallables = getTomlInstallable(toml, uri); const projectName = getTomlProjectName(toml); - if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) { + if (options?.deduplicateProjectPackages && projectName) { const normalizedName = normalizePackageName(projectName); const selected = selectedTomls.get(normalizedName); if (ambiguousProjectNames.has(normalizedName)) { @@ -466,17 +588,9 @@ export async function getProjectInstallable( } } - // Validate pyproject.toml (report only the first error found). - if (!validationError) { - const error = validatePyprojectToml(toml); - if (error) { - validationError = { - message: error, - fileUri: uri, - }; - } + if (validationError) { + validationErrors.push({ message: validationError, fileUri: uri }); } - installable.push(...tomlInstallables); } else { const name = path.basename(uri.fsPath); @@ -491,6 +605,7 @@ export async function getProjectInstallable( } throwIfCancelled(options?.token); }; + if (options?.runHeadless) { await search(options.token); } else { @@ -500,33 +615,47 @@ export async function getProjectInstallable( ); } - return { - installables: installable, - validationError, - }; + return { installables: installable, validationErrors }; +} + +export interface PyprojectValidationSelectionOptions { + /** Treat every relevant pyproject.toml as selected, as Quick Create does. */ + allPyprojectTomlsSelected?: boolean; + /** Exact dependency files selected in the configurable package picker. */ + selectedPyprojectTomls?: readonly Uri[]; } +function validationMatchesSelectedToml( + validationError: ValidationError, + selectedPyprojectTomls: readonly Uri[] | undefined, +): boolean { + const validationPath = normalizePath(path.resolve(validationError.fileUri.fsPath)); + return (selectedPyprojectTomls ?? []).some( + (uri) => normalizePath(path.resolve(uri.fsPath)) === validationPath, + ); +} + +/** + * Prompts before continuing with a selected pyproject.toml that failed parsing or validation. + * Returns false for Open, Cancel, or dismissal so callers can stop before creating an environment. + */ export async function shouldProceedAfterPyprojectValidation( - validationError: ValidationError | undefined, - install: string[], + validationErrors: readonly ValidationError[], + options?: PyprojectValidationSelectionOptions, ): Promise { - // 1. If no validation error or no installables selected, proceed - if (!validationError || install.length === 0) { + const validationError = options?.allPyprojectTomlsSelected + ? validationErrors[0] + : validationErrors.find((error) => + validationMatchesSelectedToml(error, options?.selectedPyprojectTomls), + ); + if (!validationError) { 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( + const selection = await showErrorMessage( validationError.message + Pickers.pyProject.validationErrorAction, openButton, continueButton, @@ -536,10 +665,8 @@ export async function shouldProceedAfterPyprojectValidation( if (selection === continueButton) { return true; } - if (selection === openButton) { - await window.showTextDocument(validationError.fileUri); + await showTextDocument(validationError.fileUri); } - return false; } diff --git a/src/managers/builtin/venvStepBasedFlow.ts b/src/managers/builtin/venvStepBasedFlow.ts index b5ca2b4b..0f2bb9e8 100644 --- a/src/managers/builtin/venvStepBasedFlow.ts +++ b/src/managers/builtin/venvStepBasedFlow.ts @@ -351,8 +351,9 @@ export async function createStepBasedVenvFlow( allPackages.push(...options.additionalPackages); } - const validationError = result.validationError; - const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages); + const shouldProceed = await shouldProceedAfterPyprojectValidation(result.validationErrors, { + allPyprojectTomlsSelected: true, + }); if (!shouldProceed) { return undefined; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 31f24447..829b5407 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -615,7 +615,7 @@ export async function quickCreateVenv( allPackages.push(...additionalPackages); } - const validationError = result.validationError; + const validationError = result.validationErrors[0]; if (operation && validationError) { throw new PythonToolError( 'INVALID_PROJECT', @@ -626,7 +626,11 @@ export async function quickCreateVenv( ), ); } - const shouldProceed = operation || (await shouldProceedAfterPyprojectValidation(validationError, allPackages)); + const shouldProceed = + operation || + (await shouldProceedAfterPyprojectValidation(result.validationErrors, { + allPyprojectTomlsSelected: true, + })); if (!shouldProceed) { return undefined; } diff --git a/src/managers/common/pickers.ts b/src/managers/common/pickers.ts index 222c3f0f..07e2bd9c 100644 --- a/src/managers/common/pickers.ts +++ b/src/managers/common/pickers.ts @@ -108,6 +108,7 @@ function groupByInstalled(items: PackageQuickPickItem[], installed?: string[]): interface PackagesPickerResult { install: string[]; uninstall: string[]; + selectedUris?: Uri[]; } function selectionsToResult(selections: string[], installed: string[]): PackagesPickerResult { @@ -280,11 +281,12 @@ export async function selectFromInstallableToInstall( ); if (selected) { - if (Array.isArray(selected)) { - return { install: selected.flatMap((s) => s.args ?? []), uninstall: [] }; - } else { - return { install: selected.args ?? [], uninstall: [] }; - } + const selectedItems = Array.isArray(selected) ? selected : [selected]; + return { + install: selectedItems.flatMap((item) => item.args ?? []), + uninstall: [], + selectedUris: selectedItems.flatMap((item) => (item.uri ? [item.uri] : [])), + }; } return undefined; } diff --git a/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts b/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts index 579c3c74..dfa8caef 100644 --- a/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts +++ b/src/test/managers/builtin/helpers.validatePyproject.unit.test.ts @@ -1,5 +1,8 @@ import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; import { Uri } from 'vscode'; +import * as windowApis from '../../../common/window.apis'; import { PyprojectToml, shouldProceedAfterPyprojectValidation, @@ -9,66 +12,103 @@ import { suite('pipUtils - validatePyproject', () => { suite('shouldProceedAfterPyprojectValidation', () => { + const invalidRoot = path.join(process.cwd(), 'test-data', 'invalid-project'); + const otherRoot = path.join(process.cwd(), 'test-data', 'other-project'); const mockValidationError: ValidationError = { message: 'Invalid package name "my package" in pyproject.toml.', - fileUri: Uri.file('/test/path/pyproject.toml'), + fileUri: Uri.file(path.join(invalidRoot, 'pyproject.toml')), }; + let showErrorMessage: sinon.SinonStub; + let showTextDocument: sinon.SinonStub; - test('should return true when no validation error exists', async () => { - // Arrange: no validation error - const validationError = undefined; - const install = ['-e', '/test/path']; + setup(() => { + showErrorMessage = sinon.stub(windowApis, 'showErrorMessage'); + showTextDocument = sinon.stub(windowApis, 'showTextDocument').resolves({} as never); + }); - // Act - const result = await shouldProceedAfterPyprojectValidation(validationError, install); + teardown(() => sinon.restore()); - // Assert - assert.strictEqual(result, true, 'Should proceed when no validation error'); + test('returns true when no validation error exists', async () => { + assert.strictEqual(await shouldProceedAfterPyprojectValidation([]), true); + assert.ok(showErrorMessage.notCalled); }); - test('should return true when install array is empty', async () => { - // Arrange: validation error exists but no packages selected - const install: string[] = []; + test('does not block when no pyproject.toml or a different file was selected', async () => { + assert.strictEqual(await shouldProceedAfterPyprojectValidation([mockValidationError]), true); + assert.strictEqual( + await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [Uri.file(path.join(otherRoot, 'pyproject.toml'))], + }), + true, + ); + assert.ok(showErrorMessage.notCalled); + }); - // Act - const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + test('does not confuse a bracketed sibling directory with extras from the invalid project', async () => { + const bracketedSibling = Uri.file(`${invalidRoot}[docs]` + path.sep + 'pyproject.toml'); + + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [bracketedSibling], + }); - // Assert - assert.strictEqual(result, true, 'Should proceed when no packages selected'); + assert.strictEqual(result, true); + assert.ok(showErrorMessage.notCalled); }); - 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']; + test('continues when Continue Anyway is selected for the matching pyproject', async () => { + showErrorMessage.callsFake(async (...args: unknown[]) => args[2] as never); - // Act - const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [mockValidationError.fileUri], + }); - // Assert - assert.strictEqual(result, true, 'Should proceed when no TOML packages selected'); + assert.strictEqual(result, true); + assert.ok(showErrorMessage.calledOnce); + assert.ok(showTextDocument.notCalled); }); - 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']; + test('opens the matching pyproject and stops creation when Open is selected', async () => { + showErrorMessage.callsFake(async (...args: unknown[]) => args[1] as never); - // Act - const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [mockValidationError.fileUri], + }); - // Assert - assert.strictEqual(result, true, 'Should proceed when only PyPI packages selected'); + assert.strictEqual(result, false); + assert.ok(showTextDocument.calledOnceWithExactly(mockValidationError.fileUri)); }); - 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 + test('stops creation without opening the file when Cancel is selected', async () => { + showErrorMessage.callsFake(async (...args: unknown[]) => args[3] as never); - // Act - const result = await shouldProceedAfterPyprojectValidation(mockValidationError, install); + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [mockValidationError.fileUri], + }); - // Assert - assert.strictEqual(result, true, 'Should not crash on malformed -e flag at end'); + assert.strictEqual(result, false); + assert.ok(showTextDocument.notCalled); + }); + + test('stops creation when the validation dialog is dismissed', async () => { + showErrorMessage.resolves(undefined); + + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + selectedPyprojectTomls: [mockValidationError.fileUri], + }); + + assert.strictEqual(result, false); + assert.ok(showTextDocument.notCalled); + }); + + test('prompts for an invalid-file-only Quick Create selection', async () => { + showErrorMessage.callsFake(async (...args: unknown[]) => args[3] as never); + + const result = await shouldProceedAfterPyprojectValidation([mockValidationError], { + allPyprojectTomlsSelected: true, + }); + + assert.strictEqual(result, false); + assert.ok(showErrorMessage.calledOnce); }); }); @@ -196,6 +236,65 @@ suite('pipUtils - validatePyproject', () => { }); }); + suite('validatePyprojectToml - Dependency Values', () => { + test('accepts dependency arrays containing strings', () => { + verifyValidationError( + { + project: { + name: 'test', + dependencies: ['requests>=2', 'httpx'], + 'optional-dependencies': { test: ['pytest'] }, + }, + 'build-system': { requires: ['setuptools'] }, + }, + undefined, + ); + }); + + test('rejects project dependencies that are not an array of strings', () => { + const toml = { + project: { name: 'test', dependencies: 'requests' }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /dependencies.*array of strings/i); + }); + + test('rejects non-string entries in project dependencies', () => { + const toml = { + project: { name: 'test', dependencies: ['requests', 42] }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /dependencies.*array of strings/i); + }); + + test('rejects optional dependencies that are not a table', () => { + const toml = { + project: { name: 'test', 'optional-dependencies': ['pytest'] }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /optional-dependencies.*table/i); + }); + + test('rejects invalid optional dependency group names', () => { + const toml = { + project: { name: 'test', 'optional-dependencies': { 'bad extra': ['pytest'] } }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /invalid optional dependency group name/i); + }); + + test('rejects optional dependency groups that are not arrays of strings', () => { + const toml = { + project: { name: 'test', 'optional-dependencies': { test: 'pytest' } }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /group.*array of strings/i); + }); + + test('rejects build-system requirements that are not arrays of strings', () => { + const toml = { + project: { name: 'test' }, + 'build-system': { requires: 'setuptools' }, + } as PyprojectToml; + assert.match(validatePyprojectToml(toml) ?? '', /requires.*array of strings/i); + }); + }); + suite('validatePyprojectToml - Required Fields (PEP 621)', () => { test('should accept valid project with name', () => { const toml: PyprojectToml = { diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index a6e92b6d..1a09a926 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -5,6 +5,7 @@ import { CancellationError, CancellationToken, CancellationTokenSource, LogOutpu import * as fse from 'fs-extra'; import * as os from 'os'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; +import { PackageManagement } from '../../../common/localize'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { PipListCommand } from '../../../managers/builtin/commands/list'; @@ -110,6 +111,77 @@ suite('Pip Utils - getProjectInstallable', () => { assert.ok(showQuickPick.calledOnce, 'The package picker should still open after a list failure'); }); + test('does not block configurable creation when malformed pyproject.toml is unselected', async () => { + const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'piputils-selection-')); + try { + const invalidToml = path.join(tempRoot, 'pyproject.toml'); + const requirements = path.join(tempRoot, 'requirements.txt'); + await fse.writeFile(invalidToml, '[project]\nname = "unterminated\n'); + await fse.writeFile(requirements, 'requests\n'); + mockApi.getPythonProject = () => ({ name: 'workspace', uri: Uri.file(tempRoot) }); + findFilesStub.callsFake((pattern: string) => { + if (pattern === '**/pyproject.toml') { + return Promise.resolve([Uri.file(invalidToml)]); + } + if (pattern === '*requirements*.txt') { + return Promise.resolve([Uri.file(requirements)]); + } + return Promise.resolve([]); + }); + const picker = sinon.stub(winapi, 'showQuickPickWithButtons'); + picker.onFirstCall().callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); + picker.onSecondCall().callsFake(async (items) => { + const requirement = items.find((item) => (item as { args?: string[] }).args?.[0] === '-r'); + assert.ok(requirement); + return [requirement]; + }); + const showError = sinon.stub(winapi, 'showErrorMessage').throws(new Error('Unexpected validation')); + + const result = await getWorkspacePackagesToInstall( + mockApi as PythonEnvironmentApi, + { install: [], showSkipOption: true }, + [{ name: 'workspace', uri: Uri.file(tempRoot) }], + ); + + assert.deepStrictEqual(result, { install: ['-r', Uri.file(requirements).fsPath], uninstall: [] }); + assert.ok(showError.notCalled); + } finally { + await fse.remove(tempRoot); + } + }); + + test('prompts when configurable creation selects an invalid-file-only workspace dependency', async () => { + const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'piputils-selection-')); + try { + const invalidToml = path.join(tempRoot, 'pyproject.toml'); + await fse.writeFile(invalidToml, '[project]\nname = "unterminated\n'); + mockApi.getPythonProject = () => ({ name: 'workspace', uri: Uri.file(tempRoot) }); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(invalidToml)]) + : Promise.resolve([]), + ); + sinon.stub(winapi, 'showQuickPickWithButtons').callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); + const showError = sinon.stub(winapi, 'showErrorMessage'); + showError.callsFake(async (...args: unknown[]) => args[3] as never); + + const result = await getWorkspacePackagesToInstall( + mockApi as PythonEnvironmentApi, + { install: [], showSkipOption: true }, + [{ name: 'workspace', uri: Uri.file(tempRoot) }], + ); + + assert.strictEqual(result, undefined); + assert.ok(showError.calledOnce); + } finally { + await fse.remove(tempRoot); + } + }); + test('shows progress while listing installed packages', async () => { const environment = { environmentPath: Uri.file('.'), @@ -288,7 +360,7 @@ suite('Pip Utils - getProjectInstallable', () => { deduplicateProjectPackages: true, }); assert.deepStrictEqual(result.installables.map((item) => item.uri?.fsPath), [ownRequirement.fsPath]); - assert.strictEqual(result.validationError, undefined); + assert.strictEqual(result.validationErrors[0], undefined); assert.ok(withProgressStub.notCalled); } finally { source.dispose(); @@ -447,6 +519,410 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () } }); + test('reports invalid TOML syntax instead of treating the file as empty', async () => { + const invalidToml = await writeTomlContent('', '[project]\nname = \"unterminated\n'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(projectRoot), + ); + + assert.strictEqual(result.installables.length, 0); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, invalidToml); + }); + + test('reports unreadable TOML instead of treating the file as empty', async () => { + const missingToml = path.join(projectRoot, 'missing', 'pyproject.toml'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(missingToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + ); + + assert.strictEqual(result.installables.length, 0); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, Uri.file(missingToml).fsPath); + assert.match(result.validationErrors[0]?.message ?? '', /read or parse/i); + }); + + test('reports malformed dependency values while retaining the editable install for Continue Anyway', async () => { + const invalidToml = await writeTomlContent( + '', + [ + '[project]', + 'name = "myapp"', + 'dependencies = "requests"', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + ); + + assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); + assert.match(result.validationErrors[0]?.message ?? '', /dependencies.*array of strings/i); + }); + + test('rejects datetime values where a dependency table is required', async () => { + const invalidToml = await writeTomlContent( + '', + [ + '[project]', + 'name = "myapp"', + 'optional-dependencies = 1979-05-27T07:32:00Z', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + ); + + assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); + assert.match(result.validationErrors[0]?.message ?? '', /optional-dependencies.*table/i); + }); + + test('reports invalid optional dependency group names before installation', async () => { + const invalidToml = await writeTomlContent( + '', + [ + '[project]', + 'name = "myapp"', + '', + '[project.optional-dependencies]', + '"bad extra" = ["pytest"]', + '', + ].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + ); + + assert.deepStrictEqual(result.installables[0]?.args, ['-e', `${projectRoot}[bad extra]`]); + assert.match(result.validationErrors[0]?.message ?? '', /invalid optional dependency group name/i); + }); + + test('returns a valid TOML installable without validation errors', async () => { + const validToml = await writeToml('', 'myapp'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(validToml)]) : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + ); + + assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('ignores malformed pyproject.toml from a sibling worktree outside the creation root', async () => { + const owningToml = await writeToml('main', 'myapp'); + const siblingToml = await writeTomlContent('copilot', '[project]\nname = "unterminated\n'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('does not substitute a valid sibling when the owning pyproject.toml is malformed', async () => { + const owningToml = await writeTomlContent('main', '[project]\nname = "unterminated\n'); + const siblingToml = await writeToml('copilot', 'myapp'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables, []); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); + }); + + test('ignores a non-installable schema error from a sibling worktree', async () => { + const owningToml = await writeToml('main', 'myapp'); + const siblingToml = await writeTomlContent( + 'copilot', + ['[project]', 'name = "myapp"', 'optional-dependencies = ["pytest"]', ''].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('does not substitute a valid sibling when the owning pyproject.toml has a schema error', async () => { + const owningToml = await writeTomlContent( + 'main', + ['[project]', 'name = "myapp"', 'optional-dependencies = ["pytest"]', ''].join('\n'), + ); + const siblingToml = await writeToml('copilot', 'myapp'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables, []); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); + }); + + test('ignores an installable schema-invalid sibling outside the creation root', async () => { + const owningToml = await writeToml('main', 'myapp'); + const siblingToml = await writeTomlContent( + 'copilot', + [ + 'project = "not-a-table"', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('prefers a valid same-name descendant over a disjoint sibling before duplicate selection', async () => { + const owningToml = await writeToml(path.join('main', 'pkg'), 'shared'); + const siblingToml = await writeToml('copilot', 'shared'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ + path.join(projectRoot, 'main', 'pkg'), + ]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('keeps a schema-invalid exact owner ahead of a valid same-name ancestor', async () => { + const ancestorToml = await writeToml('main', 'shared'); + const owningToml = await writeTomlContent( + path.join('main', 'pkg'), + ['[project]', 'name = "shared"', 'optional-dependencies = ["pytest"]', ''].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(ancestorToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main', 'pkg')), + ); + + assert.deepStrictEqual(result.installables, []); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); + }); + + test('ignores a same-name invalid ancestor that emits no installable when an exact owner is selected', async () => { + const ancestorToml = await writeTomlContent( + 'main', + ['[project]', 'name = "shared"', 'optional-dependencies = ["pytest"]', ''].join('\n'), + ); + const owningToml = await writeToml(path.join('main', 'pkg'), 'shared'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(ancestorToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main', 'pkg')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ + path.join(projectRoot, 'main', 'pkg'), + ]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('lets an exact valid owner replace an invalid ancestor without changing disjoint eligibility', async () => { + const ancestorToml = await writeTomlContent( + 'main', + [ + '[project]', + 'name = "shared"', + 'dependencies = "requests"', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + const owningToml = await writeToml(path.join('main', 'pkg'), 'shared'); + const siblingToml = await writeToml('copilot', 'other-package'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(ancestorToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main', 'pkg')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ + path.join(projectRoot, 'main', 'pkg'), + ]); + assert.deepStrictEqual(result.validationErrors, []); + }); + + test('keeps a schema-invalid same-name descendant eligible before duplicate selection', async () => { + const owningToml = await writeTomlContent( + path.join('main', 'pkg'), + [ + '[project]', + 'name = "shared"', + 'dependencies = "requests"', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + const siblingToml = await writeToml('copilot', 'shared'); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ + path.join(projectRoot, 'main', 'pkg'), + ]); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); + }); + + test('ignores a schema-invalid same-name sibling before duplicate selection', async () => { + const owningToml = await writeToml(path.join('main', 'pkg'), 'shared'); + const siblingToml = await writeTomlContent( + 'copilot', + [ + '[project]', + 'name = "shared"', + 'dependencies = "requests"', + '', + '[build-system]', + 'requires = ["setuptools"]', + 'build-backend = "setuptools.build_meta"', + '', + ].join('\n'), + ); + findFilesStub.callsFake((pattern: string) => + pattern === '**/pyproject.toml' + ? Promise.resolve([Uri.file(siblingToml), Uri.file(owningToml)]) + : Promise.resolve([]), + ); + + const result = await getProjectInstallable( + mockApi as PythonEnvironmentApi, + [{ name: 'myapp', uri: Uri.file(projectRoot) }], + automaticOptions(path.join(projectRoot, 'main')), + ); + + assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ + path.join(projectRoot, 'main', 'pkg'), + ]); + assert.deepStrictEqual(result.validationErrors, []); + }); + test('omits ambiguous editable installs from sibling git worktrees', async () => { // Two git worktrees checked out as sibling folders under the project root, // both declaring the same package name (issue #1627). @@ -517,7 +993,7 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () const editable = result.installables.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); assert.strictEqual(editable[0].args?.[1], path.join(projectRoot, 'z-valid')); - assert.strictEqual(result.validationError, undefined, 'ignored duplicates should not surface validation errors'); + assert.strictEqual(result.validationErrors[0], undefined, 'ignored duplicates should not surface validation errors'); }); test('reports validation from the preferred candidate instead of substituting another worktree', async () => { @@ -544,7 +1020,7 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () const editable = result.installables.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); assert.strictEqual(editable[0].args?.[1], path.join(projectRoot, 'main')); - assert.strictEqual(result.validationError?.fileUri.fsPath, invalidToml); + assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, invalidToml); }); test('does not let a metadata-only TOML with optional dependencies suppress an installable duplicate', async () => { diff --git a/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts b/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts new file mode 100644 index 00000000..a770b6eb --- /dev/null +++ b/src/test/managers/builtin/venvStepBasedFlow.unit.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi, PythonProject } from '../../../api'; +import { VenvManagerStrings } from '../../../common/localize'; +import * as telemetry from '../../../common/telemetry/sender'; +import * as windowApis from '../../../common/window.apis'; +import * as pipUtils from '../../../managers/builtin/pipUtils'; +import { createStepBasedVenvFlow } from '../../../managers/builtin/venvStepBasedFlow'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('step-based venv creation pyproject preflight', () => { + let api: PythonEnvironmentApi; + let baseEnvironment: ReturnType; + let createWithProgress: sinon.SinonStub; + let log: LogOutputChannel; + let manager: EnvironmentManager; + let project: PythonProject; + let tempRoot: string; + + setup(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'venv-step-pyproject-')); + project = { name: 'project', uri: Uri.file(tempRoot) }; + baseEnvironment = createMockPythonEnvironment({ + name: 'Python 3.12', + envPath: path.join(tempRoot, 'base', 'python.exe'), + version: '3.12.0', + }); + api = { + getPythonProject: sinon.stub().returns(project), + } as unknown as PythonEnvironmentApi; + log = { error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() } as unknown as LogOutputChannel; + manager = { log } as EnvironmentManager; + sinon.stub(telemetry, 'sendTelemetryEvent'); + createWithProgress = sinon.stub(venvUtils, 'createWithProgress'); + }); + + teardown(async () => { + sinon.restore(); + await fs.remove(tempRoot); + }); + + test('Quick Create stops before creating an environment when preflight is rejected', async () => { + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => + items.find((item) => item.label === VenvManagerStrings.quickCreate), + ); + const validationError = { + message: 'Could not read or parse pyproject.toml', + fileUri: Uri.file(path.join(tempRoot, 'pyproject.toml')), + }; + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [], + validationErrors: [validationError], + }); + const preflight = sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(false); + + const result = await createStepBasedVenvFlow( + {} as NativePythonFinder, + api, + log, + manager, + [baseEnvironment], + Uri.file(tempRoot), + { showQuickAndCustomOptions: true }, + ); + + assert.strictEqual(result, undefined); + assert.ok(preflight.calledOnceWithExactly([validationError], { allPyprojectTomlsSelected: true })); + assert.ok(createWithProgress.notCalled); + assert.strictEqual(await fs.pathExists(path.join(tempRoot, '.venv')), false); + }); + + test('configurable creation can skip workspace dependencies and still create the environment', async () => { + const quickPick = sinon.stub(windowApis, 'showQuickPickWithButtons'); + quickPick.onFirstCall().callsFake(async (items) => + items.find((item) => item.label === VenvManagerStrings.customize), + ); + quickPick.onSecondCall().callsFake(async (items) => items[0]); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('.custom-venv'); + sinon.stub(pipUtils, 'getWorkspacePackagesToInstall').resolves({ install: [], uninstall: [] }); + sinon.stub(pipUtils, 'getProjectInstallable').throws(new Error('Quick Create preflight was unexpected')); + sinon + .stub(pipUtils, 'shouldProceedAfterPyprojectValidation') + .throws(new Error('Unselected pyproject.toml was validated')); + const created = createMockPythonEnvironment({ + name: '.custom-venv', + envPath: path.join(tempRoot, '.custom-venv', 'python.exe'), + version: '3.12.0', + }); + createWithProgress.resolves({ environment: created }); + + const result = await createStepBasedVenvFlow( + {} as NativePythonFinder, + api, + log, + manager, + [baseEnvironment], + Uri.file(tempRoot), + { showQuickAndCustomOptions: true }, + ); + + assert.strictEqual(result?.environment, created); + assert.ok(createWithProgress.calledOnce); + assert.deepStrictEqual(createWithProgress.firstCall.args[7], { install: [], uninstall: [] }); + }); +}); diff --git a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts index c665d140..305aaed7 100644 --- a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts @@ -214,6 +214,138 @@ suite('createWithProgress uv tracking', () => { } }); + test('interactive quick create rejects invalid TOML before creating an environment', async () => { + const pyproject = Uri.file(path.join(tempRoot, 'pyproject.toml')); + const validationError = { message: 'Could not parse pyproject.toml', fileUri: pyproject }; + api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [], + validationErrors: [validationError], + }); + const shouldProceed = sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(false); + + const result = await quickCreateVenv( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + ); + + assert.strictEqual(result, undefined); + assert.ok(shouldProceed.calledOnceWithExactly([validationError], { allPyprojectTomlsSelected: true })); + assert.ok((builtinHelpers.runUV as sinon.SinonStub).notCalled); + assert.strictEqual(await fs.pathExists(path.join(tempRoot, '.venv')), false); + }); + + test('interactive quick create rejects an invalid optional dependency group before creation', async () => { + const pyproject = Uri.file(path.join(tempRoot, 'pyproject.toml')); + const invalidExtra = `${tempRoot}[bad extra]`; + const validationError = { + message: 'Invalid optional dependency group name "bad extra" in pyproject.toml.', + fileUri: pyproject, + }; + api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [ + { + name: 'bad extra', + displayName: 'bad extra', + uri: pyproject, + args: ['-e', invalidExtra], + }, + ], + validationErrors: [validationError], + }); + sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(false); + + const result = await quickCreateVenv( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + ); + + assert.strictEqual(result, undefined); + assert.ok((builtinHelpers.runUV as sinon.SinonStub).notCalled); + assert.strictEqual(await fs.pathExists(path.join(tempRoot, '.venv')), false); + }); + + test('Continue Anyway forwards only the filtered owning project installable', async () => { + const owner = path.join(tempRoot, 'main'); + const pyproject = Uri.file(path.join(owner, 'pyproject.toml')); + const validationError = { message: 'Invalid project dependencies', fileUri: pyproject }; + api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [ + { + name: 'pyproject.toml', + displayName: 'pyproject.toml', + uri: pyproject, + args: ['-e', owner], + }, + ], + validationErrors: [validationError], + }); + sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(true); + (builtinHelpers.runUV as sinon.SinonStub).callsFake(async (args: string[]) => { + await fs.outputFile(getVenvPythonPath(args[args.length - 1]), ''); + return ''; + }); + + const result = await quickCreateVenv( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + ); + + assert.ok(result?.environment); + assert.deepStrictEqual((api.managePackages as sinon.SinonStub).firstCall.args[1].install, ['-e', owner]); + }); + + test('interactive quick create still creates an environment for valid TOML', async () => { + const requirements = path.join(tempRoot, 'requirements.txt'); + api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [ + { + name: 'requirements.txt', + displayName: 'requirements.txt', + uri: Uri.file(requirements), + args: ['-r', requirements], + }, + ], + validationErrors: [], + }); + sinon.stub(pipUtils, 'shouldProceedAfterPyprojectValidation').resolves(true); + (builtinHelpers.runUV as sinon.SinonStub).callsFake(async (args: string[]) => { + await fs.outputFile(getVenvPythonPath(args[args.length - 1]), ''); + return ''; + }); + + const result = await quickCreateVenv( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + ); + + assert.ok(result?.environment); + assert.ok((builtinHelpers.runUV as sinon.SinonStub).calledOnce); + assert.ok((api.managePackages as sinon.SinonStub).calledOnce); + assert.deepStrictEqual((api.managePackages as sinon.SinonStub).firstCall.args[1].install, [ + '-r', + requirements, + ]); + }); test('private quick create rejects pyproject validation errors without Continue Anyway', async () => { const source = new CancellationTokenSource(); const operation: PythonToolOperation = { @@ -224,10 +356,12 @@ suite('createWithProgress uv tracking', () => { api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); sinon.stub(pipUtils, 'getProjectInstallable').resolves({ installables: [], - validationError: { - message: 'Invalid build-system', - fileUri: Uri.file(path.join(tempRoot, 'pyproject.toml')), - }, + validationErrors: [ + { + message: 'Invalid build-system', + fileUri: Uri.file(path.join(tempRoot, 'pyproject.toml')), + }, + ], }); const question = sinon .stub(pipUtils, 'shouldProceedAfterPyprojectValidation')