Skip to content

Commit 7c12038

Browse files
Use exact per-file environments for Python files (PEP 723 PR 19) (#26129)
> Part of [microsoft/vscode-python-environments#1602](microsoft/vscode-python-environments#1602). Design doc: [microsoft/vscode-python-environments#1601](microsoft/vscode-python-environments#1601). ### Roadmap context This is **cross-repository PR 19** in the PEP 723 roadmap. It closes the Python extension's per-file lookup gaps for debugging and Pylance configuration. | Cross-repository integration | PR | Status | |---|---|---| | | Environments PR 7: persisted script associations | merged ([#1697](microsoft/vscode-python-environments#1697)) | | | Environments PR 10: exact script projects | [microsoft/vscode-python-environments#1744](microsoft/vscode-python-environments#1744) | | | PR 17: Pylance per-file Python path lookup | [microsoft/pyrx#9265](microsoft/pyrx#9265) | | | **PR 19: exact Python-file lookup and debugger resolution** | **this PR** | ### Why this PR `IInterpreterService.getActiveInterpreter(resource)` normally shares in-flight, timeout, and last-known state by workspace folder. A file URI can therefore receive the workspace interpreter when a workspace lookup is already running or when exact environment resolution exceeds the timeout. That breaks two per-file consumers: - debugger launch resolution can select the workspace interpreter instead of the launch program's interpreter; - Pylance's file-scoped `workspace/configuration` request can receive a cached workspace interpreter. The exact lookup must also avoid publishing a file interpreter as a workspace-wide interpreter change. ### What this PR does - Adds an internal `exactResource` option to `IInterpreterService`. - Bypasses workspace-keyed in-flight, timeout, and last-known state for exact environment-extension lookups. - Suppresses workspace-level interpreter-change reporting for those silent exact reads. - Resolves debugger programs from: - absolute paths; - `${file}`; - `${workspaceFolder}`; - `${workspaceFolder:name}`. - Prefers the program interpreter and falls back to the normal workspace interpreter only when no exact environment is available. - Reuses the selected interpreter for both legacy `pythonPath` and command-valued `python`. - Applies activation variables when the program interpreter differs from the workspace interpreter. - Uses exact lookup only for `.py`-scoped Pylance configuration requests; workspace-level requests retain the existing cached fast path. ### Lookup semantics | Condition | Behavior | |---|---| | No concrete launch program | Preserve workspace lookup | | Exact program environment exists | Use it for debugger resolution | | Exact program lookup returns no environment | Fall back to workspace interpreter | | Program and workspace interpreters match | Preserve existing terminal activation behavior | | Program interpreter differs | Apply its activation variables | | Pylance requests `python` config for a `.py` URI | Resolve the exact file environment | | Pylance requests workspace-level config | Preserve normal workspace caching | | Exact lookup resolves an environment | Do not publish a false workspace interpreter-change event | ### Performance and safety - The existing fast workspace cache remains unchanged for normal consumers. - Exact lookup is opt-in and used only by debugger program selection and `.py` configuration scopes. - The environments extension's own URI-scoped timeout/last-known behavior remains in effect. - No public Python or environments API is changed. ### User impact Users without a per-file environment retain the same interpreter and debugger behavior. When a Python file has a distinct environment, Pylance configuration and debugger launch consistently use that file's interpreter rather than a workspace-cached value. ### Tests - Prettier check for all changed files - ESLint for all changed files - Focused middleware, resolver, launch, environment-adapter, and interpreter-service tests: **181 passing**, 3 pending - Repository-wide TypeScript compilation currently also reports two existing `TelemetryReporter` import errors in untouched files. - The full unit command was run; its failures were confined to untouched platform/path, terminal activation, activated-environment, and native-finder tests. ### Scope and follow-up This PR does not implement Pylance's open-file rerouting notification. Live movement and reanalysis after a per-file environment change remain in PR 18. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 28de9f7 commit 7c12038

12 files changed

Lines changed: 368 additions & 9 deletions

File tree

src/client/activation/languageClientMiddlewareBase.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,19 @@ 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+
// For a .py file resource, resolve the interpreter for that exact file rather than its
91+
// containing workspace folder, so a per-file environment (such as a PEP 723 inline-script
92+
// environment created for a single script) is honored for the language client hosted by
93+
// this extension. This only diverges from workspace-folder resolution when the Python
94+
// Environments extension is in use; otherwise getActiveInterpreter ignores exactResource.
95+
const exactResource = uri && path.extname(uri.fsPath).toLowerCase() === '.py';
96+
settingDict.pythonPath =
97+
(
98+
await interpreterService.getActiveInterpreter(
99+
uri,
100+
exactResource ? { exactResource: true } : undefined,
101+
)
102+
)?.path ?? 'python';
91103

92104
const env = await envService.getEnvironmentVariables(uri);
93105
const envPYTHONPATH = env.PYTHONPATH;

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
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 {
1213
getWorkspaceFolder as getVSCodeWorkspaceFolder,
1314
getWorkspaceFolders,
1415
} from '../../../../common/vscodeApis/workspaceApis';
16+
import { useEnvExtension } from '../../../../envExt/api.internal';
1517
import { IInterpreterService } from '../../../../interpreter/contracts';
1618
import { AttachRequestArguments, DebugOptions, LaunchRequestArguments, PathMapping } from '../../../types';
1719
import { PythonPathSource } from '../../types';
@@ -108,9 +110,20 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
108110
if (!debugConfiguration) {
109111
return;
110112
}
113+
delete debugConfiguration.__pythonIsProgramInterpreter;
114+
let selectedInterpreterPromise: ReturnType<IInterpreterService['getActiveInterpreter']> | undefined;
115+
const getSelectedInterpreter = () => {
116+
if (!selectedInterpreterPromise) {
117+
selectedInterpreterPromise = this.getInterpreterForDebugConfiguration(
118+
workspaceFolder,
119+
debugConfiguration,
120+
);
121+
}
122+
return selectedInterpreterPromise;
123+
};
111124
if (debugConfiguration.pythonPath === '${command:python.interpreterPath}' || !debugConfiguration.pythonPath) {
112125
const interpreterPath =
113-
(await this.interpreterService.getActiveInterpreter(workspaceFolder))?.path ??
126+
(await getSelectedInterpreter())?.path ??
114127
this.configurationService.getSettings(workspaceFolder).pythonPath;
115128
debugConfiguration.pythonPath = interpreterPath;
116129
} else {
@@ -124,7 +137,7 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
124137
if (debugConfiguration.python === '${command:python.interpreterPath}') {
125138
this.pythonPathSource = PythonPathSource.settingsJson;
126139
const interpreterPath =
127-
(await this.interpreterService.getActiveInterpreter(workspaceFolder))?.path ??
140+
(await getSelectedInterpreter())?.path ??
128141
this.configurationService.getSettings(workspaceFolder).pythonPath;
129142
debugConfiguration.python = interpreterPath;
130143
} else if (debugConfiguration.python === undefined) {
@@ -155,6 +168,63 @@ export abstract class BaseConfigurationResolver<T extends DebugConfiguration>
155168
delete debugConfiguration.pythonPath;
156169
}
157170

171+
private async getInterpreterForDebugConfiguration(
172+
workspaceFolder: Uri | undefined,
173+
debugConfiguration: LaunchRequestArguments,
174+
) {
175+
// Program-scoped (per-file) interpreter resolution only applies when the environments
176+
// extension owns interpreter resolution. Without it, preserve the historical behavior of
177+
// resolving the interpreter from the launch workspace folder, so users who are not using
178+
// the environments extension (e.g. multi-root debugging of another folder's file) see no
179+
// change.
180+
if (!useEnvExtension()) {
181+
return this.interpreterService.getActiveInterpreter(workspaceFolder);
182+
}
183+
let configuredProgram = debugConfiguration.program === '${file}' ? getProgram() : debugConfiguration.program;
184+
let programWorkspaceFolder = workspaceFolder;
185+
if (configuredProgram) {
186+
configuredProgram = configuredProgram.replace(/\$\{workspaceFolder:([^}]+)\}/g, (match, name) => {
187+
const folder = getWorkspaceFolders()?.find((candidate) => candidate.name === name);
188+
if (!folder) {
189+
return match;
190+
}
191+
programWorkspaceFolder = folder.uri;
192+
return folder.uri.fsPath;
193+
});
194+
}
195+
if (configuredProgram && workspaceFolder) {
196+
configuredProgram = configuredProgram.replace(/\$\{workspaceFolder\}/g, workspaceFolder.fsPath);
197+
}
198+
const programUri =
199+
typeof configuredProgram === 'string' &&
200+
!configuredProgram.includes('${') &&
201+
path.isAbsolute(configuredProgram)
202+
? this.getProgramUri(configuredProgram, programWorkspaceFolder)
203+
: undefined;
204+
if (programUri) {
205+
const programInterpreter = await this.interpreterService.getActiveInterpreter(programUri, {
206+
exactResource: true,
207+
});
208+
if (programInterpreter) {
209+
const workspaceInterpreter = await this.interpreterService.getActiveInterpreter(workspaceFolder, {
210+
exactResource: true,
211+
});
212+
if (!workspaceInterpreter || !arePathsSame(programInterpreter.path, workspaceInterpreter.path)) {
213+
debugConfiguration.__pythonIsProgramInterpreter = true;
214+
}
215+
return programInterpreter;
216+
}
217+
}
218+
return this.interpreterService.getActiveInterpreter(workspaceFolder);
219+
}
220+
221+
private getProgramUri(program: string, workspaceFolder: Uri | undefined): Uri {
222+
const fileUri = Uri.file(program);
223+
return workspaceFolder && workspaceFolder.scheme !== 'file'
224+
? workspaceFolder.with({ path: fileUri.path })
225+
: fileUri;
226+
}
227+
158228
protected static debugOption(debugOptions: DebugOptions[], debugOption: DebugOptions): void {
159229
if (debugOptions.indexOf(debugOption) >= 0) {
160230
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+
});

0 commit comments

Comments
 (0)