Skip to content
Draft
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
52 changes: 30 additions & 22 deletions src/managers/common/nativePythonFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,30 +832,38 @@ export async function getAllExtraSearchPaths(): Promise<string[]> {
const globalSearchPaths = getGlobalSearchPaths().filter((path) => path && path.trim() !== '');
searchDirectories.push(...globalSearchPaths);

// Get workspaceSearchPaths
const workspaceSearchPaths = getWorkspaceSearchPaths();
// Get workspaceSearchPaths — scoped per workspace folder in multi-root workspaces
const workspaceFolders = getWorkspaceFolders();
const workspaceSearchPathsPerFolder: { paths: string[]; folder?: Uri }[] = [];

if (workspaceFolders && workspaceFolders.length > 0) {
for (const folder of workspaceFolders) {
const paths = getWorkspaceSearchPaths(folder.uri);
workspaceSearchPathsPerFolder.push({ paths, folder: folder.uri });
}
} else {
// No workspace folders — fall back to unscoped call
workspaceSearchPathsPerFolder.push({ paths: getWorkspaceSearchPaths() });
}

// Resolve relative paths against workspace folders
for (const searchPath of workspaceSearchPaths) {
if (!searchPath || searchPath.trim() === '') {
continue;
}
// Resolve relative paths against the specific folder they came from
for (const { paths, folder } of workspaceSearchPathsPerFolder) {
for (const searchPath of paths) {
if (!searchPath || searchPath.trim() === '') {
continue;
}

const trimmedPath = searchPath.trim();
const trimmedPath = searchPath.trim();

if (isAbsolutePath(trimmedPath)) {
// Absolute path - use as is
searchDirectories.push(trimmedPath);
} else {
// Relative path - resolve against all workspace folders
const workspaceFolders = getWorkspaceFolders();
if (workspaceFolders) {
for (const workspaceFolder of workspaceFolders) {
const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath);
searchDirectories.push(resolvedPath);
}
if (isAbsolutePath(trimmedPath)) {
// Absolute path - use as is
searchDirectories.push(trimmedPath);
} else if (folder) {
// Relative path - resolve against the specific folder it came from
const resolvedPath = path.resolve(folder.fsPath, trimmedPath);
searchDirectories.push(resolvedPath);
} else {
traceWarn('No workspace folders found for relative search path:', trimmedPath);
traceWarn('No workspace folder for relative search path:', trimmedPath);
}
}
}
Expand Down Expand Up @@ -897,9 +905,9 @@ export function resetWorkspaceSearchPathsGlobalWarningFlag(): void {
* Gets the most specific workspace-level setting available for workspaceSearchPaths.
* Supports glob patterns which are expanded by PET.
*/
function getWorkspaceSearchPaths(): string[] {
function getWorkspaceSearchPaths(scope?: Uri): string[] {
try {
const envConfig = getConfiguration('python-envs');
const envConfig = getConfiguration('python-envs', scope);
const inspection = envConfig.inspect<string[]>('workspaceSearchPaths');

if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert';
import * as path from 'node:path';
import * as sinon from 'sinon';
import { Uri } from 'vscode';
import * as logging from '../../../common/logging';
Expand Down Expand Up @@ -480,6 +481,133 @@ suite('getAllExtraSearchPaths Integration Tests', () => {
assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders');
});

test('Multi-root workspace - each folder reads its own workspaceSearchPaths', async () => {
// Mock → Two folders with different folder-level workspaceSearchPaths
const workspace1 = Uri.file('/workspace/project1');
const workspace2 = Uri.file('/workspace/project2');

// Create separate config objects for each folder
const envConfig1: MockWorkspaceConfig = {
get: sinon.stub(),
inspect: sinon.stub(),
update: sinon.stub(),
};
const envConfig2: MockWorkspaceConfig = {
get: sinon.stub(),
inspect: sinon.stub(),
update: sinon.stub(),
};

envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
workspaceFolderValue: ['/envs/project1'],
});

envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
workspaceFolderValue: ['/envs/project2'],
});

// Return folder-specific configs based on the scope URI passed to getConfiguration
mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
if (section === 'python') {
return pythonConfig;
}
if (section === 'python-envs') {
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
return envConfig1;
}
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
return envConfig2;
}
return envConfig; // fallback for unscoped calls
}
throw new Error(`Unexpected configuration section: ${section}`);
});

pythonConfig.get.withArgs('venvPath').returns(undefined);
pythonConfig.get.withArgs('venvFolders').returns(undefined);
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);

// Run
const result = await getAllExtraSearchPaths();

// Assert - each folder's workspaceSearchPaths is read independently
assert.ok(result.includes('/envs/project1'), 'Should include project1 env path');
assert.ok(result.includes('/envs/project2'), 'Should include project2 env path');
assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
});

test('Multi-root workspace - relative paths resolved against the correct folder', async () => {
// Mock → Two folders, each with a relative workspaceSearchPaths
const workspace1 = Uri.file('/workspace/project1');
const workspace2 = Uri.file('/workspace/project2');

const envConfig1: MockWorkspaceConfig = {
get: sinon.stub(),
inspect: sinon.stub(),
update: sinon.stub(),
};
const envConfig2: MockWorkspaceConfig = {
get: sinon.stub(),
inspect: sinon.stub(),
update: sinon.stub(),
};

envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
workspaceFolderValue: ['envs'],
});

envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
workspaceFolderValue: ['venvs'],
});

mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
if (section === 'python') {
return pythonConfig;
}
if (section === 'python-envs') {
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
return envConfig1;
}
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
return envConfig2;
}
return envConfig;
}
throw new Error(`Unexpected configuration section: ${section}`);
});

pythonConfig.get.withArgs('venvPath').returns(undefined);
pythonConfig.get.withArgs('venvFolders').returns(undefined);
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);

// Run
const result = await getAllExtraSearchPaths();

// Assert - relative paths resolved only against their own folder
// .replace(/\\/g, '/') mirrors the normalization getAllExtraSearchPaths() applies to all
// returned paths, so results always use forward slashes regardless of platform.
const expected1 = path.resolve(workspace1.fsPath, 'envs').replace(/\\/g, '/');
const expected2 = path.resolve(workspace2.fsPath, 'venvs').replace(/\\/g, '/');
const wrong1In2 = path.resolve(workspace2.fsPath, 'envs').replace(/\\/g, '/');
const wrong2In1 = path.resolve(workspace1.fsPath, 'venvs').replace(/\\/g, '/');

assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
assert.ok(result.includes(expected1), 'project1/envs should come from project1 config');
assert.ok(result.includes(expected2), 'project2/venvs should come from project2 config');
assert.ok(
!result.includes(wrong1In2),
'project1 relative path should not be resolved against project2',
);
assert.ok(
!result.includes(wrong2In1),
'project2 relative path should not be resolved against project1',
);
});

test('Empty and whitespace paths are skipped', async () => {
// Mock → Mix of valid and invalid paths
pythonConfig.get.withArgs('venvPath').returns(undefined);
Expand Down