Skip to content

Commit 7c5897c

Browse files
Copilotedvilme
andauthored
fix: pass scope URI to getConfiguration in getWorkspaceSearchPaths for multi-root support
Agent-Logs-Url: https://github.com/microsoft/vscode-python-environments/sessions/dc6f6330-b142-4812-ad6e-de7204f2bb93 Co-authored-by: edvilme <5952839+edvilme@users.noreply.github.com>
1 parent b35b443 commit 7c5897c

2 files changed

Lines changed: 158 additions & 22 deletions

File tree

src/managers/common/nativePythonFinder.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -832,30 +832,38 @@ export async function getAllExtraSearchPaths(): Promise<string[]> {
832832
const globalSearchPaths = getGlobalSearchPaths().filter((path) => path && path.trim() !== '');
833833
searchDirectories.push(...globalSearchPaths);
834834

835-
// Get workspaceSearchPaths
836-
const workspaceSearchPaths = getWorkspaceSearchPaths();
835+
// Get workspaceSearchPaths — scoped per workspace folder in multi-root workspaces
836+
const workspaceFolders = getWorkspaceFolders();
837+
const workspaceSearchPathsPerFolder: { paths: string[]; folder?: Uri }[] = [];
838+
839+
if (workspaceFolders && workspaceFolders.length > 0) {
840+
for (const folder of workspaceFolders) {
841+
const paths = getWorkspaceSearchPaths(folder.uri);
842+
workspaceSearchPathsPerFolder.push({ paths, folder: folder.uri });
843+
}
844+
} else {
845+
// No workspace folders — fall back to unscoped call
846+
workspaceSearchPathsPerFolder.push({ paths: getWorkspaceSearchPaths() });
847+
}
837848

838-
// Resolve relative paths against workspace folders
839-
for (const searchPath of workspaceSearchPaths) {
840-
if (!searchPath || searchPath.trim() === '') {
841-
continue;
842-
}
849+
// Resolve relative paths against the specific folder they came from
850+
for (const { paths, folder } of workspaceSearchPathsPerFolder) {
851+
for (const searchPath of paths) {
852+
if (!searchPath || searchPath.trim() === '') {
853+
continue;
854+
}
843855

844-
const trimmedPath = searchPath.trim();
856+
const trimmedPath = searchPath.trim();
845857

846-
if (isAbsolutePath(trimmedPath)) {
847-
// Absolute path - use as is
848-
searchDirectories.push(trimmedPath);
849-
} else {
850-
// Relative path - resolve against all workspace folders
851-
const workspaceFolders = getWorkspaceFolders();
852-
if (workspaceFolders) {
853-
for (const workspaceFolder of workspaceFolders) {
854-
const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath);
855-
searchDirectories.push(resolvedPath);
856-
}
858+
if (isAbsolutePath(trimmedPath)) {
859+
// Absolute path - use as is
860+
searchDirectories.push(trimmedPath);
861+
} else if (folder) {
862+
// Relative path - resolve against the specific folder it came from
863+
const resolvedPath = path.resolve(folder.fsPath, trimmedPath);
864+
searchDirectories.push(resolvedPath);
857865
} else {
858-
traceWarn('No workspace folders found for relative search path:', trimmedPath);
866+
traceWarn('No workspace folder for relative search path:', trimmedPath);
859867
}
860868
}
861869
}
@@ -897,9 +905,9 @@ export function resetWorkspaceSearchPathsGlobalWarningFlag(): void {
897905
* Gets the most specific workspace-level setting available for workspaceSearchPaths.
898906
* Supports glob patterns which are expanded by PET.
899907
*/
900-
function getWorkspaceSearchPaths(): string[] {
908+
function getWorkspaceSearchPaths(scope?: Uri): string[] {
901909
try {
902-
const envConfig = getConfiguration('python-envs');
910+
const envConfig = getConfiguration('python-envs', scope);
903911
const inspection = envConfig.inspect<string[]>('workspaceSearchPaths');
904912

905913
if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) {

src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,134 @@ suite('getAllExtraSearchPaths Integration Tests', () => {
480480
assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders');
481481
});
482482

483+
test('Multi-root workspace - each folder reads its own workspaceSearchPaths', async () => {
484+
// Mock → Two folders with different folder-level workspaceSearchPaths
485+
const workspace1 = Uri.file('/workspace/project1');
486+
const workspace2 = Uri.file('/workspace/project2');
487+
488+
// Create separate config objects for each folder
489+
const envConfig1: MockWorkspaceConfig = {
490+
get: sinon.stub(),
491+
inspect: sinon.stub(),
492+
update: sinon.stub(),
493+
};
494+
const envConfig2: MockWorkspaceConfig = {
495+
get: sinon.stub(),
496+
inspect: sinon.stub(),
497+
update: sinon.stub(),
498+
};
499+
500+
envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
501+
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
502+
workspaceFolderValue: ['/envs/project1'],
503+
});
504+
505+
envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
506+
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
507+
workspaceFolderValue: ['/envs/project2'],
508+
});
509+
510+
// Return folder-specific configs based on the scope URI passed to getConfiguration
511+
mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
512+
if (section === 'python') {
513+
return pythonConfig;
514+
}
515+
if (section === 'python-envs') {
516+
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
517+
return envConfig1;
518+
}
519+
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
520+
return envConfig2;
521+
}
522+
return envConfig; // fallback for unscoped calls
523+
}
524+
throw new Error(`Unexpected configuration section: ${section}`);
525+
});
526+
527+
pythonConfig.get.withArgs('venvPath').returns(undefined);
528+
pythonConfig.get.withArgs('venvFolders').returns(undefined);
529+
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);
530+
531+
// Run
532+
const result = await getAllExtraSearchPaths();
533+
534+
// Assert - each folder's workspaceSearchPaths is read independently
535+
assert.ok(result.includes('/envs/project1'), 'Should include project1 env path');
536+
assert.ok(result.includes('/envs/project2'), 'Should include project2 env path');
537+
assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
538+
});
539+
540+
test('Multi-root workspace - relative paths resolved against the correct folder', async () => {
541+
// Mock → Two folders, each with a relative workspaceSearchPaths
542+
const workspace1 = Uri.file('/workspace/project1');
543+
const workspace2 = Uri.file('/workspace/project2');
544+
545+
const envConfig1: MockWorkspaceConfig = {
546+
get: sinon.stub(),
547+
inspect: sinon.stub(),
548+
update: sinon.stub(),
549+
};
550+
const envConfig2: MockWorkspaceConfig = {
551+
get: sinon.stub(),
552+
inspect: sinon.stub(),
553+
update: sinon.stub(),
554+
};
555+
556+
envConfig1.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
557+
envConfig1.inspect.withArgs('workspaceSearchPaths').returns({
558+
workspaceFolderValue: ['envs'],
559+
});
560+
561+
envConfig2.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
562+
envConfig2.inspect.withArgs('workspaceSearchPaths').returns({
563+
workspaceFolderValue: ['venvs'],
564+
});
565+
566+
mockGetConfiguration.callsFake((section: string, scope?: unknown) => {
567+
if (section === 'python') {
568+
return pythonConfig;
569+
}
570+
if (section === 'python-envs') {
571+
if (scope && (scope as Uri).fsPath === workspace1.fsPath) {
572+
return envConfig1;
573+
}
574+
if (scope && (scope as Uri).fsPath === workspace2.fsPath) {
575+
return envConfig2;
576+
}
577+
return envConfig;
578+
}
579+
throw new Error(`Unexpected configuration section: ${section}`);
580+
});
581+
582+
pythonConfig.get.withArgs('venvPath').returns(undefined);
583+
pythonConfig.get.withArgs('venvFolders').returns(undefined);
584+
mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]);
585+
586+
// Run
587+
const result = await getAllExtraSearchPaths();
588+
589+
// Assert - relative paths resolved only against their own folder
590+
assert.strictEqual(result.length, 2, 'Should have exactly 2 paths (one per folder)');
591+
assert.ok(
592+
result.some((p) => p.includes('project1') && p.endsWith('/envs')),
593+
'project1/envs should come from project1 config',
594+
);
595+
assert.ok(
596+
result.some((p) => p.includes('project2') && p.endsWith('/venvs')),
597+
'project2/venvs should come from project2 config',
598+
);
599+
// project1 relative path must NOT be resolved against project2
600+
assert.ok(
601+
!result.some((p) => p.includes('project2') && p.endsWith('/envs')),
602+
'project1 relative path should not be resolved against project2',
603+
);
604+
// project2 relative path must NOT be resolved against project1
605+
assert.ok(
606+
!result.some((p) => p.includes('project1') && p.endsWith('/venvs')),
607+
'project2 relative path should not be resolved against project1',
608+
);
609+
});
610+
483611
test('Empty and whitespace paths are skipped', async () => {
484612
// Mock → Mix of valid and invalid paths
485613
pythonConfig.get.withArgs('venvPath').returns(undefined);

0 commit comments

Comments
 (0)