diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index afd5946b..5e288ef2 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -832,30 +832,38 @@ export async function getAllExtraSearchPaths(): Promise { 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); } } } @@ -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('workspaceSearchPaths'); if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) { diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 6159fdc2..617d38d7 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -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'; @@ -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);