Skip to content

Commit 67eedfe

Browse files
karthiknadigCopiloteleanorjboyd
authored
fix: resolve command names in python.defaultInterpreterPath on PATH (Fixes #1884) (#1888)
`python.defaultInterpreterPath` set to a command name such as `python3` is now looked up on `PATH` instead of being joined to the workspace folder, and the default value `python` is treated as not configured, as in the Python extension. Previously both showed "Default interpreter path '…' could not be resolved" at startup and the setting was ignored. - `toAbsoluteInterpreterPath`: a value without a path separator is looked up on `PATH` first; other relative paths still resolve against the workspace folder (#1603). - `findCommandOnPath`: considers only absolute `PATH` entries and, on Windows, only `.com`/`.exe` files, since batch-file shims (for example pyenv-win's) cannot be started without a shell. It accepts App Execution Aliases (`WindowsApps\python3.exe`), which exist but cannot be `stat`ed, so `which` misses them. - `python`, the setting's default, skips priority 3 without a warning, so auto-discovery still prefers a workspace `.venv`. This matches the Python extension's `DEFAULT_INTERPRETER_SETTING` handling. - Not changed: absolute paths and `${workspaceFolder}` values; values that cannot be resolved still warn. - When the command resolves to a global interpreter that is not the newest one installed, the status bar and Run use it only once #1887 is merged (#1885). ## Before / after Fresh profile, folder without a venv, user setting `"python.defaultInterpreterPath": "python3"` (`python3` on `PATH` is Python 3.14.8). ### Before ![Before: "could not be resolved" warning; the setting is ignored](https://github.com/karthiknadig/vscode-python-environments/blob/814e9be7cb27036fb8d3e3957ff1d586dc6cbbf9/default-interpreter-command/before.gif?raw=true) ### After ![After: no warning; python3 from PATH is used](https://github.com/karthiknadig/vscode-python-environments/blob/814e9be7cb27036fb8d3e3957ff1d586dc6cbbf9/default-interpreter-command/after.gif?raw=true) ## Validation | Check | Result | |-------|--------| | UI scenario on `main` @ 52f6ace, `"python3"` | ❌ warning; `source: autoDiscovery` | | UI scenario with this PR, `"python3"` | ✅ no warning; `source: defaultInterpreterPath`, 3/3 | | UI scenario, `"python"` | `main`: ❌ warning; this PR: ✅ no warning, `source: autoDiscovery` | | `npm run lint`, `npm run compile-tests`, `npm run unittest` | ✅ 2546 passing (5 new tests in `interpreterSelection.unit.test.ts`, suite "bare command names") | Fixes #1884 --- Created by Copilot --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 95f7baf commit 67eedfe

2 files changed

Lines changed: 219 additions & 9 deletions

File tree

‎src/features/interpreterSelection.ts‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
import * as fs from 'fs-extra';
45
import * as path from 'path';
56
import { commands, ConfigurationChangeEvent, Disposable, l10n, Uri } from 'vscode';
67
import { PythonEnvironment, PythonEnvironmentApi } from '../api';
@@ -11,6 +12,7 @@ import { EventNames } from '../common/telemetry/constants';
1112
import { sendTelemetryEvent } from '../common/telemetry/sender';
1213
import { resolveVariables } from '../common/utils/internalVariables';
1314
import { normalizePath } from '../common/utils/pathUtils';
15+
import { isWindows } from '../common/utils/platformUtils';
1416
import { showWarningMessage } from '../common/window.apis';
1517
import {
1618
getConfiguration,
@@ -27,6 +29,12 @@ import type {
2729
import type { InternalEnvironmentManager } from '../managers/common/registeredManagers';
2830
import { NativeEnvInfo, NativePythonFinder } from '../managers/common/nativePythonFinder';
2931

32+
/**
33+
* The default value of `python.defaultInterpreterPath`. Like the Python extension, treat it as "not
34+
* configured" so auto-discovery (which prefers a workspace venv) picks the interpreter.
35+
*/
36+
const DEFAULT_INTERPRETER_PATH_SETTING = 'python';
37+
3038
/**
3139
* Result from the priority chain resolution.
3240
*/
@@ -109,7 +117,11 @@ async function resolvePriorityChainCore(
109117

110118
// PRIORITY 3: User-configured python.defaultInterpreterPath
111119
const userInterpreterPath = getUserConfiguredSetting<string>('python', 'defaultInterpreterPath', scope);
112-
if (userInterpreterPath) {
120+
if (userInterpreterPath === DEFAULT_INTERPRETER_PATH_SETTING) {
121+
traceVerbose(
122+
`${logPrefix} defaultInterpreterPath is the default '${userInterpreterPath}', using auto-discovery`,
123+
);
124+
} else if (userInterpreterPath) {
113125
const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope);
114126
if (expandedInterpreterPath.includes('${')) {
115127
if (scope) {
@@ -135,7 +147,7 @@ async function resolvePriorityChainCore(
135147
// Resolve relative paths against the workspace folder so the native finder doesn't
136148
// resolve them against an unrelated current working directory (which can produce a
137149
// malformed, duplicated path such as <workspace>/<workspace-name>/.venv/...).
138-
const absoluteInterpreterPath = toAbsoluteInterpreterPath(expandedInterpreterPath, scope);
150+
const absoluteInterpreterPath = await toAbsoluteInterpreterPath(expandedInterpreterPath, scope);
139151
const resolved = await tryResolveInterpreterPath(nativeFinder, api, absoluteInterpreterPath, envManagers);
140152
if (resolved) {
141153
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
@@ -534,24 +546,77 @@ function getProjectSpecificEnvManager(projectManager: PythonProjectManager, scop
534546
/**
535547
* Resolve a (variable-expanded) interpreter path to an absolute path.
536548
*
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.
549+
* A bare command name such as `python3` refers to the interpreter found on `PATH`, as in a terminal.
550+
* Other relative paths (e.g. `.venv/bin/python`) are resolved against the workspace folder
551+
* identified by the scope, because the native finder would resolve them against its own,
552+
* unrelated working directory and can produce malformed paths (including a duplicated
553+
* workspace segment). Global relative paths are left unchanged because there is no workspace
554+
* context to resolve against.
542555
*
543556
* @param interpreterPath - The interpreter path after variable substitution.
544557
* @param scope - The workspace folder URI, or undefined for global scope.
545558
* @returns An absolute interpreter path when possible, otherwise the input unchanged.
546559
*/
547-
function toAbsoluteInterpreterPath(interpreterPath: string, scope: Uri | undefined): string {
548-
if (path.isAbsolute(interpreterPath) || !scope) {
560+
async function toAbsoluteInterpreterPath(interpreterPath: string, scope: Uri | undefined): Promise<string> {
561+
if (path.isAbsolute(interpreterPath)) {
562+
return interpreterPath;
563+
}
564+
if (!/[\\/]/.test(interpreterPath)) {
565+
const onPath = await findCommandOnPath(interpreterPath);
566+
if (onPath) {
567+
return onPath;
568+
}
569+
}
570+
if (!scope) {
549571
return interpreterPath;
550572
}
551573
const workspaceFolder = getWorkspaceFolder(scope);
552574
return path.resolve(workspaceFolder?.uri.fsPath ?? scope.fsPath, interpreterPath);
553575
}
554576

577+
/**
578+
* Finds an executable on `PATH` and returns its absolute path.
579+
*
580+
* On Windows only `.com` and `.exe` files are considered, as when a process is started without a
581+
* shell; batch-file shims cannot be launched that way. Unlike `which`, this accepts Windows App
582+
* Execution Aliases (the `python.exe` that the Microsoft Store and the Python install manager place
583+
* in `WindowsApps`), which exist but cannot be `stat`ed. Relative `PATH` entries are skipped because
584+
* they would resolve against the extension host's working directory.
585+
*/
586+
async function findCommandOnPath(command: string): Promise<string | undefined> {
587+
const extensions = isWindows() ? ['.com', '.exe'] : [''];
588+
const names =
589+
isWindows() && extensions.some((ext) => command.toLowerCase().endsWith(ext))
590+
? [command]
591+
: extensions.map((ext) => command + ext);
592+
for (const entry of (process.env.PATH ?? '').split(path.delimiter)) {
593+
const dir = entry.replace(/^"(.*)"$/, '$1');
594+
if (!dir || !path.isAbsolute(dir)) {
595+
continue;
596+
}
597+
for (const name of names) {
598+
const candidate = path.join(dir, name);
599+
if (await isCommandFile(candidate)) {
600+
return candidate;
601+
}
602+
}
603+
}
604+
return undefined;
605+
}
606+
607+
async function isCommandFile(candidate: string): Promise<boolean> {
608+
try {
609+
if (isWindows()) {
610+
await fs.access(candidate, fs.constants.F_OK);
611+
return true;
612+
}
613+
await fs.access(candidate, fs.constants.X_OK);
614+
return (await fs.stat(candidate)).isFile();
615+
} catch {
616+
return false;
617+
}
618+
}
619+
555620
/**
556621
* Try to resolve an interpreter path via nativeFinder and return a PriorityChainResult.
557622
* Returns undefined if resolution fails.

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
// Licensed under the MIT License.
33

44
import * as assert from 'assert';
5+
import * as fs from 'fs-extra';
6+
import * as os from 'os';
57
import * as path from 'path';
68
import * as sinon from 'sinon';
79
import { ConfigurationChangeEvent, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode';
810
import { PythonEnvironment, PythonEnvironmentApi, PythonProject, SetEnvironmentScope } from '../../api';
11+
import { normalizePath } from '../../common/utils/pathUtils';
912
import * as windowApis from '../../common/window.apis';
1013
import * as workspaceApis from '../../common/workspace.apis';
1114
import {
@@ -396,6 +399,148 @@ suite('Interpreter Selection - Priority Chain', () => {
396399
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath));
397400
});
398401

402+
suite('bare command names', () => {
403+
let commandDir: string;
404+
let originalPath: string | undefined;
405+
406+
/** Creates an executable named `name` in a temporary directory placed first on PATH. */
407+
async function putCommandOnPath(name: string): Promise<string> {
408+
commandDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dip-command-'));
409+
const executable = path.join(commandDir, process.platform === 'win32' ? `${name}.exe` : name);
410+
await fs.writeFile(executable, '');
411+
await fs.chmod(executable, 0o755);
412+
originalPath = process.env.PATH;
413+
process.env.PATH = `${commandDir}${path.delimiter}${originalPath ?? ''}`;
414+
return executable;
415+
}
416+
417+
function useDefaultInterpreterPath(value: string): void {
418+
sandbox
419+
.stub(helpers, 'getUserConfiguredSetting')
420+
.callsFake((section: string, key: string) =>
421+
section === 'python' && key === 'defaultInterpreterPath' ? value : undefined,
422+
);
423+
}
424+
425+
teardown(async () => {
426+
if (originalPath !== undefined) {
427+
process.env.PATH = originalPath;
428+
originalPath = undefined;
429+
}
430+
if (commandDir) {
431+
await fs.remove(commandDir);
432+
}
433+
});
434+
435+
for (const testCase of [
436+
{
437+
scope: 'workspace folder',
438+
resolve: async () => {
439+
const workspaceUri = Uri.file(path.resolve('test', 'workspace'));
440+
const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder;
441+
sandbox
442+
.stub(workspaceApis, 'getConfiguration')
443+
.returns(createMockConfig([]) as WorkspaceConfiguration);
444+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder);
445+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
446+
return resolveEnvironmentByPriority(
447+
workspaceUri,
448+
mockEnvManagers as unknown as EnvironmentManagers,
449+
mockProjectManager as unknown as PythonProjectManager,
450+
mockNativeFinder as unknown as NativePythonFinder,
451+
mockApi as unknown as PythonEnvironmentApi,
452+
);
453+
},
454+
},
455+
{
456+
scope: 'global scope',
457+
resolve: async () => {
458+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]);
459+
return resolveGlobalEnvironmentByPriority(
460+
mockEnvManagers as unknown as EnvironmentManagers,
461+
mockNativeFinder as unknown as NativePythonFinder,
462+
mockApi as unknown as PythonEnvironmentApi,
463+
);
464+
},
465+
},
466+
]) {
467+
test(`should resolve a command name from PATH for the ${testCase.scope}`, async () => {
468+
const executable = await putCommandOnPath('pyenvs-test-python3');
469+
useDefaultInterpreterPath('pyenvs-test-python3');
470+
mockNativeFinder.resolve.resolves({ executable, version: '3.12.4', prefix: commandDir });
471+
mockApi.resolveEnvironment.resolves(mockSystemEnv);
472+
473+
const result = await testCase.resolve();
474+
475+
assert.strictEqual(result.source, 'defaultInterpreterPath');
476+
assert.strictEqual(
477+
normalizePath(mockNativeFinder.resolve.firstCall.args[0]),
478+
normalizePath(executable),
479+
);
480+
});
481+
}
482+
483+
test('should resolve a command name that is not on PATH against the workspace folder', async () => {
484+
const workspaceUri = Uri.file(path.resolve('test', 'workspace'));
485+
const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder;
486+
const expected = path.resolve(workspaceUri.fsPath, 'pyenvs-test-missing-python');
487+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
488+
sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder);
489+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]);
490+
useDefaultInterpreterPath('pyenvs-test-missing-python');
491+
mockNativeFinder.resolve.rejects(new Error('not found'));
492+
mockVenvManager.get.resolves(undefined);
493+
494+
await resolveEnvironmentByPriority(
495+
workspaceUri,
496+
mockEnvManagers as unknown as EnvironmentManagers,
497+
mockProjectManager as unknown as PythonProjectManager,
498+
mockNativeFinder as unknown as NativePythonFinder,
499+
mockApi as unknown as PythonEnvironmentApi,
500+
);
501+
502+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(expected));
503+
});
504+
505+
test('should treat the default value "python" as not configured', async () => {
506+
await putCommandOnPath('python');
507+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
508+
useDefaultInterpreterPath('python');
509+
mockVenvManager.get.resolves(mockVenvEnv);
510+
511+
const result = await resolveEnvironmentByPriority(
512+
testUri,
513+
mockEnvManagers as unknown as EnvironmentManagers,
514+
mockProjectManager as unknown as PythonProjectManager,
515+
mockNativeFinder as unknown as NativePythonFinder,
516+
mockApi as unknown as PythonEnvironmentApi,
517+
);
518+
519+
assert.strictEqual(result.source, 'autoDiscovery');
520+
assert.strictEqual(result.environment, mockVenvEnv);
521+
assert.ok(mockNativeFinder.resolve.notCalled);
522+
});
523+
524+
test('should not pick a batch file from PATH on Windows', async function () {
525+
if (process.platform !== 'win32') {
526+
this.skip();
527+
}
528+
const shim = await putCommandOnPath('pyenvs-test-shim');
529+
await fs.move(shim, path.join(commandDir, 'pyenvs-test-shim.bat'));
530+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]);
531+
useDefaultInterpreterPath('pyenvs-test-shim');
532+
mockNativeFinder.resolve.rejects(new Error('not found'));
533+
534+
await resolveGlobalEnvironmentByPriority(
535+
mockEnvManagers as unknown as EnvironmentManagers,
536+
mockNativeFinder as unknown as NativePythonFinder,
537+
mockApi as unknown as PythonEnvironmentApi,
538+
);
539+
540+
assert.ok(mockNativeFinder.resolve.calledOnceWithExactly('pyenvs-test-shim'));
541+
});
542+
});
543+
399544
test('should pass an absolute defaultInterpreterPath to the native finder unchanged', async () => {
400545
// Absolute paths must not be re-resolved against the workspace folder.
401546
const workspaceUri = Uri.file(path.resolve('/test/workspace'));

0 commit comments

Comments
 (0)