Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 237 additions & 110 deletions src/managers/builtin/pipUtils.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/managers/builtin/venvStepBasedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}
Expand Down
12 changes: 7 additions & 5 deletions src/managers/common/pickers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
175 changes: 137 additions & 38 deletions src/test/managers/builtin/helpers.validatePyproject.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading