Skip to content

Commit e413c74

Browse files
Use exact per-file environments for Python files
Resolve debugger programs and Pylance Python-file configuration against the exact file resource without publishing false workspace interpreter changes. Preserve workspace fallback and activate differing program environments. Part of microsoft/vscode-python-environments#1602. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a4fa16e commit e413c74

12 files changed

Lines changed: 325 additions & 9 deletions

File tree

src/client/activation/languageClientMiddlewareBase.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ export class LanguageClientMiddlewareBase implements Middleware {
8787
const settingDict: LSPObject & { pythonPath: string; _envPYTHONPATH: string } = settings[
8888
i
8989
] as LSPObject & { pythonPath: string; _envPYTHONPATH: string };
90-
settingDict.pythonPath = (await interpreterService.getActiveInterpreter(uri))?.path ?? 'python';
90+
const exactResource = uri && path.extname(uri.fsPath).toLowerCase() === '.py';
91+
settingDict.pythonPath =
92+
(
93+
await interpreterService.getActiveInterpreter(
94+
uri,
95+
exactResource ? { exactResource: true } : undefined,
96+
)
97+
)?.path ?? 'python';
9198

9299
const env = await envService.getEnvironmentVariables(uri);
93100
const envPYTHONPATH = env.PYTHONPATH;

src/client/debugger/extension/configuration/resolvers/base.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { injectable } from 'inversify';
77
import * as path from 'path';
88
import { CancellationToken, DebugConfiguration, Uri, WorkspaceFolder } from 'vscode';
9+
import { arePathsSame } from '../../../../common/platform/fs-paths';
910
import { IConfigurationService } from '../../../../common/types';
1011
import { getOSType, OSType } from '../../../../common/utils/platform';
1112
import {
@@ -108,9 +109,20 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
108109
if (!debugConfiguration) {
109110
return;
110111
}
112+
delete debugConfiguration.__pythonIsProgramInterpreter;
113+
let selectedInterpreterPromise: ReturnType<IInterpreterService['getActiveInterpreter']> | undefined;
114+
const getSelectedInterpreter = () => {
115+
if (!selectedInterpreterPromise) {
116+
selectedInterpreterPromise = this.getInterpreterForDebugConfiguration(
117+
workspaceFolder,
118+
debugConfiguration,
119+
);
120+
}
121+
return selectedInterpreterPromise;
122+
};
111123
if (debugConfiguration.pythonPath === '${command:python.interpreterPath}' || !debugConfiguration.pythonPath) {
112124
const interpreterPath =
113-
(await this.interpreterService.getActiveInterpreter(workspaceFolder))?.path ??
125+
(await getSelectedInterpreter())?.path ??
114126
this.configurationService.getSettings(workspaceFolder).pythonPath;
115127
debugConfiguration.pythonPath = interpreterPath;
116128
} else {
@@ -124,7 +136,7 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
124136
if (debugConfiguration.python === '${command:python.interpreterPath}') {
125137
this.pythonPathSource = PythonPathSource.settingsJson;
126138
const interpreterPath =
127-
(await this.interpreterService.getActiveInterpreter(workspaceFolder))?.path ??
139+
(await getSelectedInterpreter())?.path ??
128140
this.configurationService.getSettings(workspaceFolder).pythonPath;
129141
debugConfiguration.python = interpreterPath;
130142
} else if (debugConfiguration.python === undefined) {
@@ -155,6 +167,55 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
155167
delete debugConfiguration.pythonPath;
156168
}
157169

170+
private async getInterpreterForDebugConfiguration(
171+
workspaceFolder: Uri | undefined,
172+
debugConfiguration: LaunchRequestArguments,
173+
) {
174+
let configuredProgram = debugConfiguration.program === '${file}' ? getProgram() : debugConfiguration.program;
175+
let programWorkspaceFolder = workspaceFolder;
176+
if (configuredProgram) {
177+
configuredProgram = configuredProgram.replace(/\$\{workspaceFolder:([^}]+)\}/g, (match, name) => {
178+
const folder = getWorkspaceFolders()?.find((candidate) => candidate.name === name);
179+
if (!folder) {
180+
return match;
181+
}
182+
programWorkspaceFolder = folder.uri;
183+
return folder.uri.fsPath;
184+
});
185+
}
186+
if (configuredProgram && workspaceFolder) {
187+
configuredProgram = configuredProgram.replace(/\$\{workspaceFolder\}/g, workspaceFolder.fsPath);
188+
}
189+
const programUri =
190+
typeof configuredProgram === 'string' &&
191+
!configuredProgram.includes('${') &&
192+
path.isAbsolute(configuredProgram)
193+
? this.getProgramUri(configuredProgram, programWorkspaceFolder)
194+
: undefined;
195+
if (programUri) {
196+
const programInterpreter = await this.interpreterService.getActiveInterpreter(programUri, {
197+
exactResource: true,
198+
});
199+
if (programInterpreter) {
200+
const workspaceInterpreter = await this.interpreterService.getActiveInterpreter(workspaceFolder, {
201+
exactResource: true,
202+
});
203+
if (!workspaceInterpreter || !arePathsSame(programInterpreter.path, workspaceInterpreter.path)) {
204+
debugConfiguration.__pythonIsProgramInterpreter = true;
205+
}
206+
return programInterpreter;
207+
}
208+
}
209+
return this.interpreterService.getActiveInterpreter(workspaceFolder);
210+
}
211+
212+
private getProgramUri(program: string, workspaceFolder: Uri | undefined): Uri {
213+
const fileUri = Uri.file(program);
214+
return workspaceFolder && workspaceFolder.scheme !== 'file'
215+
? workspaceFolder.with({ path: fileUri.path })
216+
: fileUri;
217+
}
218+
158219
protected static debugOption(debugOptions: DebugOptions[], debugOption: DebugOptions): void {
159220
if (debugOptions.indexOf(debugOption) >= 0) {
160221
return;

src/client/debugger/extension/configuration/resolvers/launch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,12 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver<Launc
118118
debugConfiguration.envFile = settings.envFile;
119119
}
120120
let baseEnvVars: EnvironmentVariables | undefined;
121-
if (this.isCustomPythonSet || debugConfiguration.console !== 'integratedTerminal') {
121+
const shouldActivateEnvironment =
122+
this.isCustomPythonSet ||
123+
debugConfiguration.__pythonIsProgramInterpreter ||
124+
debugConfiguration.console !== 'integratedTerminal';
125+
delete debugConfiguration.__pythonIsProgramInterpreter;
126+
if (shouldActivateEnvironment) {
122127
// We only have the right activated environment present in integrated terminal if no custom Python path
123128
// is specified. Otherwise, we need to explicitly set the variables.
124129
baseEnvVars = await this.environmentActivationService.getActivatedEnvironmentVariables(

src/client/debugger/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ interface IKnownLaunchRequestArguments extends ICommonDebugArguments {
111111
// and "debugLauncherPython" all at once.
112112
pythonPath?: string;
113113

114+
// Whether the selected interpreter came from the program resource rather than the workspace.
115+
__pythonIsProgramInterpreter?: boolean;
116+
114117
// Configures automatic code reloading.
115118
autoReload?: IAutomaticCodeReload;
116119

src/client/envExt/api.legacy.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,15 @@ async function resolveActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnv
125125
return newEnv;
126126
}
127127

128-
export async function getActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
128+
export async function getActiveInterpreterLegacy(
129+
resource?: Uri,
130+
options?: { reportActiveInterpreterChanged?: boolean },
131+
): Promise<PythonEnvironmentLegacy | undefined> {
132+
if (options?.reportActiveInterpreterChanged === false) {
133+
const pythonEnv = await getEnvironment(resource);
134+
return pythonEnv ? toLegacyType(pythonEnv) : undefined;
135+
}
136+
129137
// De-duplicate concurrent resolutions for the same resource. The underlying
130138
// `getEnvironment` call can block while the environments extension is performing a
131139
// refresh, so multiple startup callers (e.g. the language server watcher and the

src/client/interpreter/contracts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export interface ICondaService {
7272
}
7373

7474
export const IInterpreterService = Symbol('IInterpreterService');
75+
export interface GetActiveInterpreterOptions {
76+
/**
77+
* Resolve the exact resource without using workspace-scoped in-flight or last-known state.
78+
*/
79+
exactResource?: boolean;
80+
}
81+
7582
export interface IInterpreterService {
7683
triggerRefresh(query?: PythonLocatorQuery, options?: TriggerRefreshOptions): Promise<void>;
7784
readonly refreshPromise: Promise<void> | undefined;
@@ -90,7 +97,7 @@ export interface IInterpreterService {
9097
* @deprecated Only exists for old Jupyter integration.
9198
*/
9299
getAllInterpreters(resource?: Uri): Promise<PythonEnvironment[]>;
93-
getActiveInterpreter(resource?: Uri): Promise<PythonEnvironment | undefined>;
100+
getActiveInterpreter(resource?: Uri, options?: GetActiveInterpreterOptions): Promise<PythonEnvironment | undefined>;
94101
getInterpreterDetails(pythonPath: string, resoure?: Uri): Promise<undefined | PythonEnvironment>;
95102
refresh(resource: Resource): Promise<void>;
96103
initialize(): void;

src/client/interpreter/interpreterService.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { PythonEnvironment } from '../pythonEnvironments/info';
2727
import {
2828
IActivatedEnvironmentLaunch,
2929
IComponentAdapter,
30+
GetActiveInterpreterOptions,
3031
IInterpreterDisplay,
3132
IInterpreterService,
3233
IInterpreterStatusbarVisibilityFilter,
@@ -253,7 +254,17 @@ export class InterpreterService implements Disposable, IInterpreterService {
253254
this.didChangeInterpreterInformation.dispose();
254255
}
255256

256-
public async getActiveInterpreter(resource?: Uri): Promise<PythonEnvironment | undefined> {
257+
public async getActiveInterpreter(
258+
resource?: Uri,
259+
options?: GetActiveInterpreterOptions,
260+
): Promise<PythonEnvironment | undefined> {
261+
if (options?.exactResource && useEnvExtension()) {
262+
return getActiveInterpreterLegacy(resource, { reportActiveInterpreterChanged: false }).catch((ex) => {
263+
traceError('Failed to get active interpreter', ex);
264+
return undefined;
265+
});
266+
}
267+
257268
const workspaceService = this.serviceContainer.get<IWorkspaceService>(IWorkspaceService);
258269
const key = workspaceService.getWorkspaceFolderIdentifier(resource);
259270

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { expect } from 'chai';
7+
import * as sinon from 'sinon';
8+
import { CancellationTokenSource, Uri } from 'vscode';
9+
import { ConfigurationRequest } from 'vscode-languageclient';
10+
import { LanguageClientMiddlewareBase } from '../../client/activation/languageClientMiddlewareBase';
11+
import { LanguageServerType } from '../../client/activation/types';
12+
import { IEnvironmentVariablesProvider } from '../../client/common/variables/types';
13+
import { IInterpreterService } from '../../client/interpreter/contracts';
14+
import { IServiceContainer } from '../../client/ioc/types';
15+
16+
suite('LanguageClientMiddlewareBase', () => {
17+
test('uses exact interpreter lookup only for Python file configuration scopes', async () => {
18+
const getActiveInterpreter = sinon.stub().resolves({ path: '/env/python' });
19+
const getEnvironmentVariables = sinon.stub().resolves({});
20+
const serviceContainer = ({
21+
get: (service: symbol) => {
22+
if (service === IInterpreterService) {
23+
return { getActiveInterpreter };
24+
}
25+
if (service === IEnvironmentVariablesProvider) {
26+
return { getEnvironmentVariables };
27+
}
28+
throw new Error(`Unexpected service: ${service.toString()}`);
29+
},
30+
} as unknown) as IServiceContainer;
31+
const middleware = new LanguageClientMiddlewareBase(serviceContainer, LanguageServerType.Node, sinon.stub());
32+
const next = sinon.stub().resolves([{}, {}]) as ConfigurationRequest.HandlerSignature;
33+
const script = Uri.file('/workspace/script.py');
34+
const workspace = Uri.file('/workspace');
35+
const tokenSource = new CancellationTokenSource();
36+
37+
const result = await middleware.workspace.configuration(
38+
{
39+
items: [
40+
{ section: 'python', scopeUri: script.toString() },
41+
{ section: 'python', scopeUri: workspace.toString() },
42+
],
43+
},
44+
tokenSource.token,
45+
next,
46+
);
47+
48+
expect(result).to.deep.equal([{ pythonPath: '/env/python' }, { pythonPath: '/env/python' }]);
49+
expect(getActiveInterpreter.firstCall.args[0].toString()).to.equal(script.toString());
50+
expect(getActiveInterpreter.firstCall.args[1]).to.deep.equal({ exactResource: true });
51+
expect(getActiveInterpreter.secondCall.args[0].toString()).to.equal(workspace.toString());
52+
expect(getActiveInterpreter.secondCall.args[1]).to.equal(undefined);
53+
tokenSource.dispose();
54+
});
55+
});

src/test/debugger/extension/configuration/resolvers/base.unit.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { expect } from 'chai';
88
import * as path from 'path';
99
import * as sinon from 'sinon';
10-
import { anything, instance, mock, when } from 'ts-mockito';
10+
import { anything, capture, instance, mock, verify, when } from 'ts-mockito';
1111
import { DebugConfiguration, Uri, WorkspaceFolder } from 'vscode';
1212
import { CancellationToken } from 'vscode-jsonrpc';
1313
import { ConfigurationService } from '../../../../../client/common/configuration/service';
@@ -289,6 +289,91 @@ suite('Debugging - Config Resolver', () => {
289289
expect(config).to.have.property('debugLauncherPython', pythonPath);
290290
});
291291

292+
test('uses one exact program lookup for command-valued pythonPath and python', async () => {
293+
const workspaceUri = Uri.file(path.resolve('workspace'));
294+
const programPath = path.join(workspaceUri.fsPath, 'script.py');
295+
const workspacePython = path.resolve('workspace-env', 'python');
296+
const programPython = path.resolve('program-env', 'python');
297+
const config = {
298+
program: path.join('${workspaceFolder}', 'script.py'),
299+
pythonPath: '${command:python.interpreterPath}',
300+
python: '${command:python.interpreterPath}',
301+
};
302+
when(interpreterService.getActiveInterpreter(anything(), anything())).thenCall(async (resource) =>
303+
resource?.fsPath === Uri.file(programPath).fsPath
304+
? ({ path: programPython } as PythonEnvironment)
305+
: ({ path: workspacePython } as PythonEnvironment),
306+
);
307+
308+
await resolver.resolveAndUpdatePythonPath(workspaceUri, config as LaunchRequestArguments);
309+
310+
expect(config).to.not.have.property('pythonPath');
311+
expect(config).to.have.property('python', programPython);
312+
expect(config).to.have.property('__pythonIsProgramInterpreter', true);
313+
verify(interpreterService.getActiveInterpreter(anything(), anything())).twice();
314+
verify(interpreterService.getActiveInterpreter(anything())).never();
315+
const [resource, options] = capture(interpreterService.getActiveInterpreter).first();
316+
expect(resource?.fsPath).to.equal(Uri.file(programPath).fsPath);
317+
expect(options).to.deep.equal({ exactResource: true });
318+
});
319+
320+
test('falls back to the workspace interpreter when exact program lookup has no environment', async () => {
321+
const workspaceUri = Uri.file(path.resolve('workspace'));
322+
const programPath = path.join(workspaceUri.fsPath, 'script.py');
323+
const workspacePython = path.resolve('workspace-env', 'python');
324+
const config = { program: programPath };
325+
when(interpreterService.getActiveInterpreter(anything(), anything())).thenResolve(undefined);
326+
when(interpreterService.getActiveInterpreter(anything())).thenResolve({
327+
path: workspacePython,
328+
} as PythonEnvironment);
329+
330+
await resolver.resolveAndUpdatePythonPath(workspaceUri, config as LaunchRequestArguments);
331+
332+
expect(config).to.have.property('python', workspacePython);
333+
expect(config).to.not.have.property('__pythonIsProgramInterpreter');
334+
verify(interpreterService.getActiveInterpreter(anything(), anything())).once();
335+
verify(interpreterService.getActiveInterpreter(anything())).once();
336+
});
337+
338+
test('resolves a named workspace-folder program before exact interpreter lookup', async () => {
339+
const launchWorkspaceUri = Uri.file(path.resolve('workspace-a'));
340+
const programWorkspaceUri = Uri.file(path.resolve('workspace-b'));
341+
const programPath = path.join(programWorkspaceUri.fsPath, 'script.py');
342+
const workspacePython = path.resolve('workspace-env', 'python');
343+
const programPython = path.resolve('program-env', 'python');
344+
const config = { program: path.join('${workspaceFolder:program-root}', 'script.py') };
345+
getWorkspaceFoldersStub.returns([
346+
{ uri: launchWorkspaceUri, name: 'launch-root', index: 0 },
347+
{ uri: programWorkspaceUri, name: 'program-root', index: 1 },
348+
]);
349+
when(interpreterService.getActiveInterpreter(anything(), anything())).thenCall(async (resource) =>
350+
resource?.fsPath === Uri.file(programPath).fsPath
351+
? ({ path: programPython } as PythonEnvironment)
352+
: ({ path: workspacePython } as PythonEnvironment),
353+
);
354+
355+
await resolver.resolveAndUpdatePythonPath(launchWorkspaceUri, config as LaunchRequestArguments);
356+
357+
expect(config).to.have.property('python', programPython);
358+
const [resource] = capture(interpreterService.getActiveInterpreter).first();
359+
expect(resource?.fsPath).to.equal(Uri.file(programPath).fsPath);
360+
});
361+
362+
test('does not mark a program interpreter that matches the workspace interpreter', async () => {
363+
const workspaceUri = Uri.file(path.resolve('workspace'));
364+
const programPath = path.join(workspaceUri.fsPath, 'script.py');
365+
const pythonPath = path.resolve('env', 'python');
366+
const config = { program: programPath };
367+
when(interpreterService.getActiveInterpreter(anything(), anything())).thenResolve({
368+
path: pythonPath,
369+
} as PythonEnvironment);
370+
371+
await resolver.resolveAndUpdatePythonPath(workspaceUri, config as LaunchRequestArguments);
372+
373+
expect(config).to.have.property('python', pythonPath);
374+
expect(config).to.not.have.property('__pythonIsProgramInterpreter');
375+
});
376+
292377
const localHostTestMatrix: Record<string, boolean> = {
293378
localhost: true,
294379
'127.0.0.1': true,

0 commit comments

Comments
 (0)