Skip to content

Commit 14cecd8

Browse files
Copilotedvilme
andauthored
Fix ${workspaceFolder} resolution and relative path handling for defaultInterpreterPath (#1603)
Fixes #1586 `python.defaultInterpreterPath` failed to resolve in two cases: `${workspaceFolder}` was left unexpanded when VS Code couldn't determine the owning folder (e.g. Windows drive-letter casing), and relative paths were resolved against an unrelated CWD by the native finder—producing malformed, duplicated paths like `<workspace>/<workspace-name>/.venv/...`. ### Changes - **`internalVariables.ts`** — When `getWorkspaceFolder()` returns `undefined` for a project scope, fall back to the single open workspace folder so `${workspaceFolder}` still expands. - **`interpreterSelection.ts`** — Added `toAbsoluteInterpreterPath()` to resolve relative interpreter paths against the workspace folder (with the same single-folder fallback) before handing them to the native finder. Absolute paths pass through unchanged. - **Tests** — Added unit coverage for the new edge cases: - `${workspaceFolder}` single-folder fallback, multi-folder no-op, no-scope, and no-folders-open cases. - Relative path resolution via owning folder and via single-folder fallback, multi-folder pass-through, and absolute-path pass-through. ### Notes - `resolveVariables()` is separator-preserving string substitution, so the new assertions intentionally compare forward-slash inputs rather than `path.join()`. Relative-path expectations use `path.resolve()` / `Uri.file().fsPath` to stay cross-platform. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Eduardo Villalpando Mello <eduardovil@microsoft.com> Copilot-Session: 9ad4171d-6839-4aec-93b4-c6bca21b7329
1 parent f5c2d50 commit 14cecd8

4 files changed

Lines changed: 291 additions & 32 deletions

File tree

‎src/common/utils/internalVariables.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@ export function resolveVariables(value: string, project?: Uri, env?: { [key: str
1212
substitutions.set('${pythonProject}', project.fsPath);
1313
}
1414

15-
const workspace = project ? getWorkspaceFolder(project) : undefined;
15+
// Resolve ${workspaceFolder} for a project (workspace) scope. Prefer the workspace folder
16+
// that owns the project URI, but fall back to the single open workspace folder when the
17+
// owning folder can't be determined (e.g. drive-letter casing differences on Windows can
18+
// cause workspace.getWorkspaceFolder() to return undefined). Without this fallback the
19+
// ${workspaceFolder} token would be left unexpanded and downstream path resolution fails.
20+
let workspace = project ? getWorkspaceFolder(project) : undefined;
21+
if (!workspace && project) {
22+
const folders = getWorkspaceFolders();
23+
if (folders && folders.length === 1) {
24+
workspace = folders[0];
25+
}
26+
}
1627
if (workspace) {
1728
substitutions.set('${workspaceFolder}', workspace.uri.fsPath);
1829
}

‎src/features/interpreterSelection.ts‎

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -132,38 +132,24 @@ async function resolvePriorityChainCore(
132132
);
133133
}
134134
} else {
135-
const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope);
136-
if (expandedInterpreterPath.includes('${')) {
137-
traceWarn(
138-
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' contains unresolved variables, falling back to auto-discovery`,
139-
);
140-
const error: SettingResolutionError = {
141-
setting: 'defaultInterpreterPath',
142-
kind: 'pathUnresolvedVariables',
143-
configuredValue: userInterpreterPath,
144-
};
145-
errors.push(error);
146-
} else {
147-
const resolved = await tryResolveInterpreterPath(
148-
nativeFinder,
149-
api,
150-
expandedInterpreterPath,
151-
envManagers,
152-
);
153-
if (resolved) {
154-
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
155-
return { result: resolved, errors };
156-
}
157-
const error: SettingResolutionError = {
158-
setting: 'defaultInterpreterPath',
159-
kind: 'pathCannotResolve',
160-
configuredValue: userInterpreterPath,
161-
};
162-
errors.push(error);
163-
traceWarn(
164-
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' unresolvable, falling back to auto-discovery`,
165-
);
135+
// Resolve relative paths against the workspace folder so the native finder doesn't
136+
// resolve them against an unrelated current working directory (which can produce a
137+
// malformed, duplicated path such as <workspace>/<workspace-name>/.venv/...).
138+
const absoluteInterpreterPath = toAbsoluteInterpreterPath(expandedInterpreterPath, scope);
139+
const resolved = await tryResolveInterpreterPath(nativeFinder, api, absoluteInterpreterPath, envManagers);
140+
if (resolved) {
141+
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
142+
return { result: resolved, errors };
166143
}
144+
const error: SettingResolutionError = {
145+
setting: 'defaultInterpreterPath',
146+
kind: 'pathCannotResolve',
147+
configuredValue: userInterpreterPath,
148+
};
149+
errors.push(error);
150+
traceWarn(
151+
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' unresolvable, falling back to auto-discovery`,
152+
);
167153
}
168154
}
169155

@@ -545,6 +531,27 @@ function getProjectSpecificEnvManager(projectManager: PythonProjectManager, scop
545531
return undefined;
546532
}
547533

534+
/**
535+
* Resolve a (variable-expanded) interpreter path to an absolute path.
536+
*
537+
* `python.defaultInterpreterPath` may be configured as a relative path (e.g. `.venv/bin/python`).
538+
* The native finder resolves relative paths against its own working directory, which is unrelated
539+
* to the workspace and can produce malformed paths (including a duplicated workspace segment).
540+
* To avoid this, relative paths are resolved against the workspace folder identified by the scope.
541+
* Global relative paths are left unchanged because there is no workspace context to resolve against.
542+
*
543+
* @param interpreterPath - The interpreter path after variable substitution.
544+
* @param scope - The workspace folder URI, or undefined for global scope.
545+
* @returns An absolute interpreter path when possible, otherwise the input unchanged.
546+
*/
547+
function toAbsoluteInterpreterPath(interpreterPath: string, scope: Uri | undefined): string {
548+
if (path.isAbsolute(interpreterPath) || !scope) {
549+
return interpreterPath;
550+
}
551+
const workspaceFolder = getWorkspaceFolder(scope);
552+
return path.resolve(workspaceFolder?.uri.fsPath ?? scope.fsPath, interpreterPath);
553+
}
554+
548555
/**
549556
* Try to resolve an interpreter path via nativeFinder and return a PriorityChainResult.
550557
* Returns undefined if resolution fails.

‎src/test/common/internalVariables.unit.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,48 @@ suite('Internal Variable substitution', () => {
4242
assert.equal(result, `Some ${item.substitution} text ${item.substitution}`);
4343
});
4444
});
45+
46+
test('Resolve ${workspaceFolder} via single-folder fallback when owning folder is undefined', () => {
47+
// Simulates the Windows scenario where workspace.getWorkspaceFolder() fails to find the
48+
// owning folder (e.g. drive-letter casing). With a single open folder, the token should
49+
// still resolve instead of being left as a literal ${workspaceFolder}.
50+
getWorkspaceFolderStub.returns(undefined);
51+
getWorkspaceFoldersStub.returns([workspaceFolder]);
52+
53+
const result = resolveVariables('${workspaceFolder}/.venv/Scripts/python.exe', project.uri as unknown as Uri);
54+
55+
assert.equal(result, `${workspaceFolder.uri.fsPath}/.venv/Scripts/python.exe`);
56+
assert.ok(!result.includes('${workspaceFolder}'), 'token should be expanded');
57+
});
58+
59+
test('Leaves ${workspaceFolder} unresolved when no project scope is provided', () => {
60+
// Global scope (no project) must not resolve workspace-specific variables.
61+
getWorkspaceFolderStub.returns(undefined);
62+
getWorkspaceFoldersStub.returns([workspaceFolder]);
63+
64+
const result = resolveVariables('${workspaceFolder}/.venv/Scripts/python.exe');
65+
66+
assert.equal(result, '${workspaceFolder}/.venv/Scripts/python.exe');
67+
});
68+
69+
test('Does not use single-folder fallback for ${workspaceFolder} with multiple folders', () => {
70+
const otherFolder = { name: 'workspace2', uri: { fsPath: path.join(home, 'workspace2') } };
71+
getWorkspaceFolderStub.returns(undefined);
72+
getWorkspaceFoldersStub.returns([workspaceFolder, otherFolder]);
73+
74+
const result = resolveVariables('${workspaceFolder}/.venv/Scripts/python.exe', project.uri as unknown as Uri);
75+
76+
assert.equal(result, '${workspaceFolder}/.venv/Scripts/python.exe');
77+
});
78+
79+
test('Leaves ${workspaceFolder} unresolved when no folders are open', () => {
80+
// No owning folder and no open folders at all (getWorkspaceFolders returns undefined):
81+
// the single-folder fallback must not crash and the token stays literal.
82+
getWorkspaceFolderStub.returns(undefined);
83+
getWorkspaceFoldersStub.returns(undefined);
84+
85+
const result = resolveVariables('${workspaceFolder}/.venv/Scripts/python.exe', project.uri as unknown as Uri);
86+
87+
assert.equal(result, '${workspaceFolder}/.venv/Scripts/python.exe');
88+
});
4589
});

‎src/test/features/interpreterSelection.unit.test.ts‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,173 @@ suite('Interpreter Selection - Priority Chain', () => {
268268
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(expandedInterpreterPath));
269269
});
270270

271+
test('should resolve a relative defaultInterpreterPath against the workspace folder', async () => {
272+
// A relative path must be made absolute against the workspace folder so the native
273+
// finder does not resolve it against an unrelated working directory (which can yield
274+
// a malformed, duplicated path such as <workspace>/<workspace-name>/.venv/...).
275+
const workspaceUri = Uri.file(path.resolve('/test/workspace'));
276+
const absoluteInterpreterPath = path.resolve(workspaceUri.fsPath, '.venv/bin/python');
277+
const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder;
278+
279+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
280+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder);
281+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
282+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
283+
if (section === 'python' && key === 'defaultInterpreterPath') {
284+
return './.venv/bin/python';
285+
}
286+
return undefined;
287+
});
288+
mockNativeFinder.resolve.resolves({
289+
executable: absoluteInterpreterPath,
290+
version: '3.11.0',
291+
prefix: path.dirname(path.dirname(absoluteInterpreterPath)),
292+
});
293+
mockApi.resolveEnvironment.resolves({
294+
...mockVenvEnv,
295+
displayPath: absoluteInterpreterPath,
296+
environmentPath: Uri.file(absoluteInterpreterPath),
297+
execInfo: { run: { executable: absoluteInterpreterPath } },
298+
});
299+
300+
const result = await resolveEnvironmentByPriority(
301+
workspaceUri,
302+
mockEnvManagers as unknown as EnvironmentManagers,
303+
mockProjectManager as unknown as PythonProjectManager,
304+
mockNativeFinder as unknown as NativePythonFinder,
305+
mockApi as unknown as PythonEnvironmentApi,
306+
);
307+
308+
assert.strictEqual(result.source, 'defaultInterpreterPath');
309+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath));
310+
// The path passed to the native finder must not duplicate the workspace folder name.
311+
const passedPath = mockNativeFinder.resolve.firstCall.args[0] as string;
312+
assert.ok(
313+
!passedPath.includes(`workspace${path.sep}workspace`),
314+
`path should not duplicate the workspace folder: ${passedPath}`,
315+
);
316+
});
317+
318+
test('should resolve a relative defaultInterpreterPath from its scope when folder lookup fails', async () => {
319+
// The scope already identifies the workspace folder, so drive-letter casing must not
320+
// prevent relative path resolution when getWorkspaceFolder returns undefined.
321+
const workspaceUri = Uri.file(path.resolve('/test/workspace'));
322+
const absoluteInterpreterPath = path.resolve(workspaceUri.fsPath, '.venv/bin/python');
323+
const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder;
324+
325+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
326+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(undefined);
327+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
328+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
329+
if (section === 'python' && key === 'defaultInterpreterPath') {
330+
return './.venv/bin/python';
331+
}
332+
return undefined;
333+
});
334+
mockNativeFinder.resolve.resolves({
335+
executable: absoluteInterpreterPath,
336+
version: '3.11.0',
337+
prefix: path.dirname(path.dirname(absoluteInterpreterPath)),
338+
});
339+
mockApi.resolveEnvironment.resolves({
340+
...mockVenvEnv,
341+
displayPath: absoluteInterpreterPath,
342+
environmentPath: Uri.file(absoluteInterpreterPath),
343+
execInfo: { run: { executable: absoluteInterpreterPath } },
344+
});
345+
346+
const result = await resolveEnvironmentByPriority(
347+
workspaceUri,
348+
mockEnvManagers as unknown as EnvironmentManagers,
349+
mockProjectManager as unknown as PythonProjectManager,
350+
mockNativeFinder as unknown as NativePythonFinder,
351+
mockApi as unknown as PythonEnvironmentApi,
352+
);
353+
354+
assert.strictEqual(result.source, 'defaultInterpreterPath');
355+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath));
356+
});
357+
358+
test('should resolve a relative defaultInterpreterPath from its scope with multiple folders open', async () => {
359+
const workspaceUri = Uri.file(path.resolve('/test/workspace'));
360+
const otherUri = Uri.file(path.resolve('/test/other'));
361+
const relativeInterpreterPath = './.venv/bin/python';
362+
const absoluteInterpreterPath = path.resolve(workspaceUri.fsPath, relativeInterpreterPath);
363+
364+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
365+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(undefined);
366+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([
367+
{ name: 'workspace', uri: workspaceUri } as WorkspaceFolder,
368+
{ name: 'other', uri: otherUri } as WorkspaceFolder,
369+
]);
370+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
371+
if (section === 'python' && key === 'defaultInterpreterPath') {
372+
return relativeInterpreterPath;
373+
}
374+
return undefined;
375+
});
376+
mockNativeFinder.resolve.resolves({
377+
executable: absoluteInterpreterPath,
378+
version: '3.11.0',
379+
prefix: path.dirname(path.dirname(absoluteInterpreterPath)),
380+
});
381+
mockApi.resolveEnvironment.resolves({
382+
...mockVenvEnv,
383+
displayPath: absoluteInterpreterPath,
384+
environmentPath: Uri.file(absoluteInterpreterPath),
385+
execInfo: { run: { executable: absoluteInterpreterPath } },
386+
});
387+
388+
await resolveEnvironmentByPriority(
389+
workspaceUri,
390+
mockEnvManagers as unknown as EnvironmentManagers,
391+
mockProjectManager as unknown as PythonProjectManager,
392+
mockNativeFinder as unknown as NativePythonFinder,
393+
mockApi as unknown as PythonEnvironmentApi,
394+
);
395+
396+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath));
397+
});
398+
399+
test('should pass an absolute defaultInterpreterPath to the native finder unchanged', async () => {
400+
// Absolute paths must not be re-resolved against the workspace folder.
401+
const workspaceUri = Uri.file(path.resolve('/test/workspace'));
402+
const absoluteInterpreterPath = Uri.file(path.resolve('/opt/python/bin/python')).fsPath;
403+
const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder;
404+
405+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
406+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder);
407+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
408+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
409+
if (section === 'python' && key === 'defaultInterpreterPath') {
410+
return absoluteInterpreterPath;
411+
}
412+
return undefined;
413+
});
414+
mockNativeFinder.resolve.resolves({
415+
executable: absoluteInterpreterPath,
416+
version: '3.11.0',
417+
prefix: path.dirname(path.dirname(absoluteInterpreterPath)),
418+
});
419+
mockApi.resolveEnvironment.resolves({
420+
...mockSystemEnv,
421+
displayPath: absoluteInterpreterPath,
422+
environmentPath: Uri.file(absoluteInterpreterPath),
423+
execInfo: { run: { executable: absoluteInterpreterPath } },
424+
});
425+
426+
const result = await resolveEnvironmentByPriority(
427+
workspaceUri,
428+
mockEnvManagers as unknown as EnvironmentManagers,
429+
mockProjectManager as unknown as PythonProjectManager,
430+
mockNativeFinder as unknown as NativePythonFinder,
431+
mockApi as unknown as PythonEnvironmentApi,
432+
);
433+
434+
assert.strictEqual(result.source, 'defaultInterpreterPath');
435+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath));
436+
});
437+
271438
test('should skip native resolution when defaultInterpreterPath has unresolved variables', async () => {
272439
// When resolveVariables can't resolve ${workspaceFolder} (e.g., global scope with no workspace),
273440
// the path still contains '${' and should be skipped without calling nativeFinder.resolve
@@ -1183,6 +1350,36 @@ suite('Interpreter Selection - resolveGlobalEnvironmentByPriority', () => {
11831350
assert.strictEqual(result.environment.execInfo?.run?.executable, userPyenvPath);
11841351
});
11851352

1353+
test('should not resolve a global relative defaultInterpreterPath against the open workspace', async () => {
1354+
const relativeInterpreterPath = path.join('.venv', process.platform === 'win32' ? 'Scripts' : 'bin', 'python');
1355+
const workspaceFolder = {
1356+
name: 'workspace',
1357+
uri: Uri.file(path.resolve('/test/workspace')),
1358+
} as WorkspaceFolder;
1359+
1360+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
1361+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
1362+
if (section === 'python' && key === 'defaultInterpreterPath') {
1363+
return relativeInterpreterPath;
1364+
}
1365+
return undefined;
1366+
});
1367+
mockNativeFinder.resolve.resolves({
1368+
executable: relativeInterpreterPath,
1369+
version: '3.11.0',
1370+
prefix: '.venv',
1371+
});
1372+
mockApi.resolveEnvironment.resolves(mockSystemEnv);
1373+
1374+
await resolveGlobalEnvironmentByPriority(
1375+
mockEnvManagers as unknown as EnvironmentManagers,
1376+
mockNativeFinder as unknown as NativePythonFinder,
1377+
mockApi as unknown as PythonEnvironmentApi,
1378+
);
1379+
1380+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(relativeInterpreterPath));
1381+
});
1382+
11861383
test('should use original user path for global scope even when nativeFinder resolves to different executable', async () => {
11871384
// This is the key bug fix test - user configures pyenv path, native finder returns homebrew
11881385
const userPyenvPath = '/Users/test/.pyenv/versions/3.13.7/bin/python';

0 commit comments

Comments
 (0)