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
38 changes: 25 additions & 13 deletions src/features/interpreterSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,32 @@ async function resolvePriorityChainCore(
const userInterpreterPath = getUserConfiguredSetting<string>('python', 'defaultInterpreterPath', scope);
if (userInterpreterPath) {
const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope);
const resolved = await tryResolveInterpreterPath(nativeFinder, api, expandedInterpreterPath, envManagers);
if (resolved) {
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
return { result: resolved, errors };
if (expandedInterpreterPath.includes('${')) {
traceWarn(
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' contains unresolved variables, falling back to auto-discovery`,
);
const error: SettingResolutionError = {
setting: 'defaultInterpreterPath',
configuredValue: userInterpreterPath,
reason: l10n.t('Path contains unresolved variables'),
};
errors.push(error);
} else {
const resolved = await tryResolveInterpreterPath(nativeFinder, api, expandedInterpreterPath, envManagers);
if (resolved) {
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
return { result: resolved, errors };
}
const error: SettingResolutionError = {
setting: 'defaultInterpreterPath',
configuredValue: userInterpreterPath,
reason: `Could not resolve interpreter path '${userInterpreterPath}'`,
};
Comment on lines +126 to +130
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SettingResolutionError.reason is surfaced to users via notifyUserOfSettingErrors() (it gets interpolated into the localized showWarningMessage() string). In this branch the reason is still a hard-coded English string (and other reasons in this flow are a mix of localized/unlocalized), which will show up unlocalized in non-English VS Code installs. Please localize this reason (and ideally make the approach consistent for all SettingResolutionError.reason values, e.g., store a reason code and localize at display time).

Copilot uses AI. Check for mistakes.
errors.push(error);
traceWarn(
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' unresolvable, falling back to auto-discovery`,
);
}
const error: SettingResolutionError = {
setting: 'defaultInterpreterPath',
configuredValue: userInterpreterPath,
reason: `Could not resolve interpreter path '${userInterpreterPath}'`,
};
errors.push(error);
traceWarn(
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' unresolvable, falling back to auto-discovery`,
);
}

// PRIORITY 4: Auto-discovery (no user-configured settings matched)
Expand Down
12 changes: 11 additions & 1 deletion src/managers/common/nativePythonFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,15 @@ function getGlobalSearchPaths(): string[] {
}
}

let workspaceSearchPathsGlobalWarningShown = false;

/**
* @internal Test-only helper to reset the workspaceSearchPaths global-level warning flag.
*/
export function resetWorkspaceSearchPathsGlobalWarningFlag(): void {
workspaceSearchPathsGlobalWarningShown = false;
}

/**
* Gets the most specific workspace-level setting available for workspaceSearchPaths.
* Supports glob patterns which are expanded by PET.
Expand All @@ -851,7 +860,8 @@ function getWorkspaceSearchPaths(): string[] {
const envConfig = getConfiguration('python-envs');
const inspection = envConfig.inspect<string[]>('workspaceSearchPaths');

if (inspection?.globalValue) {
if (inspection?.globalValue && !workspaceSearchPathsGlobalWarningShown) {
workspaceSearchPathsGlobalWarningShown = true;
traceError(
'python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.',
);
Expand Down
30 changes: 30 additions & 0 deletions src/test/features/interpreterSelection.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,36 @@ suite('Interpreter Selection - Priority Chain', () => {
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(expandedInterpreterPath));
});

test('should skip native resolution when defaultInterpreterPath has unresolved variables', async () => {
// When resolveVariables can't resolve ${workspaceFolder} (e.g., global scope with no workspace),
// the path still contains '${' and should be skipped without calling nativeFinder.resolve
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]);
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(undefined);
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
if (section === 'python' && key === 'defaultInterpreterPath') {
return '${workspaceFolder}/.venv/bin/python3';
}
return undefined;
});
mockVenvManager.get.resolves(mockVenvEnv);

const result = await resolveEnvironmentByPriority(
testUri,
mockEnvManagers as unknown as EnvironmentManagers,
mockProjectManager as unknown as PythonProjectManager,
mockNativeFinder as unknown as NativePythonFinder,
mockApi as unknown as PythonEnvironmentApi,
);

// Should fall through to auto-discovery without calling nativeFinder.resolve
assert.strictEqual(result.source, 'autoDiscovery');
assert.ok(
mockNativeFinder.resolve.notCalled,
'nativeFinder.resolve should not be called with unresolved variables',
);
});

test('should fall through to Priority 4 when defaultInterpreterPath cannot be resolved', async () => {
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as pathUtils from '../../../common/utils/pathUtils';
import * as workspaceApis from '../../../common/workspace.apis';

// Import the function under test
import { getAllExtraSearchPaths } from '../../../managers/common/nativePythonFinder';
import { getAllExtraSearchPaths, resetWorkspaceSearchPathsGlobalWarningFlag } from '../../../managers/common/nativePythonFinder';

interface MockWorkspaceConfig {
get: sinon.SinonStub;
Expand All @@ -26,6 +26,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => {
let envConfig: MockWorkspaceConfig;

setup(() => {
resetWorkspaceSearchPathsGlobalWarningFlag();

// Mock VS Code workspace APIs
mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration');
mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders');
Expand Down Expand Up @@ -87,6 +89,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => {

teardown(() => {
sinon.restore();
resetWorkspaceSearchPathsGlobalWarningFlag();
});

suite('Legacy Path Consolidation Tests', () => {
Expand Down Expand Up @@ -332,6 +335,33 @@ suite('getAllExtraSearchPaths Integration Tests', () => {
);
});

test('Global workspace setting warning is only logged once across multiple calls', async () => {
// Mock → Workspace setting incorrectly set at global level
pythonConfig.get.withArgs('venvPath').returns(undefined);
pythonConfig.get.withArgs('venvFolders').returns(undefined);
envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] });
envConfig.inspect.withArgs('workspaceSearchPaths').returns({
globalValue: ['should-be-ignored'],
});

// Run multiple times
await getAllExtraSearchPaths();
await getAllExtraSearchPaths();
await getAllExtraSearchPaths();

// Assert - error should only be logged once, not three times
const matchingCalls = mockTraceError
.getCalls()
.filter((call: sinon.SinonSpyCall) =>
/workspaceSearchPaths.*global.*level/i.test(String(call.args[0])),
);
assert.strictEqual(
matchingCalls.length,
1,
`Expected exactly 1 error about workspaceSearchPaths global level, got ${matchingCalls.length}`,
);
});

test('Configuration read errors return empty arrays', async () => {
// Mock → Configuration throws errors
pythonConfig.get.withArgs('venvPath').returns(undefined);
Expand Down