From b0a41ac075adc423e3689f2b3a26fd7d4266ddc0 Mon Sep 17 00:00:00 2001 From: sharon Date: Mon, 24 Feb 2025 11:26:38 -0500 Subject: [PATCH] Add `python.interpreters.include` and `python.interpreters.exclude` settings (#6398) ### Summary - addresses #3574 for both the JS and native locators - also implements #6254 for the JS locator (previously [only implemented for the native locator](https://github.com/posit-dev/positron/pull/6261)) - introduces new settings - `python.interpreters.include`: List of folders to search for Python installations. These folders are searched in addition to the default folders for your operating system. - `python.interpreters.exclude`: List of interpreter paths or folders to exclude from the available Python installations. These interpreters will not be displayed in the Positron UI. This option takes precedence over the `include` option. - adds new command available via Command Palette: `python.interpreters.debugInfo`: "Print interpreter debug information to Output" - Opens the Python Language Pack Output and prints python interpreter settings info as well as info about each discovered python runtime #### Settings UI image #### Settings JSON ```json "python.interpreters.include": [ "~/scratch" ], "python.interpreters.exclude": [ "/usr/bin/python3", "/opt/homebrew", "/usr/local/bin", "/opt/python", "~/scratch" ] ``` #### Python Language Pack Output Some log prefixes/messages to search for: - `pythonRuntimeDiscoverer`: to find out if any runtimes were filtered out during discovery - `shouldIncludeInterpreter`: whether or not the interpreter will be included based on the user settings - `Not registering runtime ${extraData.pythonPath} as it is excluded via user settings.`: if the runtime is not getting registered because the user excluded it - `getAdditionalEnvDirs`: native python finder -- list all the additional directories being searched, such as the user-included dirs
Sample Output from `python.interpreters.debugInfo`: "Print interpreter debug information to Output" ```js 2025-02-24 10:43:17.026 [info] ===================================================================== 2025-02-24 10:43:17.026 [info] =============== [START] PYTHON INTERPRETER DEBUG INFO =============== 2025-02-24 10:43:17.026 [info] ===================================================================== 2025-02-24 10:43:17.026 [info] Python interpreter settings: { defaultInterpreterPath: '/Users/hello/qa-example-content/.venv/bin/python', 'interpreters.include': [ '/Users/hello/scratch' ], 'interpreters.exclude': [ '/usr/bin/python3', '/opt/homebrew', '/usr/local/bin', '/opt/python', '/Users/hello/scratch' ] } 2025-02-24 10:43:17.026 [info] Python interpreters discovered: [ { name: 'Python 3.13.1 64-bit', path: '/opt/homebrew/bin/python3', versionInfo: { version: '3.13.1', supportedVersion: true }, envInfo: { envType: 'Global', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: false, excludedInSettings: true } }, { name: 'Python 3.10.4 64-bit', path: '/opt/python/3.10.4/bin/python', versionInfo: { version: '3.10.4', supportedVersion: true }, envInfo: { envType: 'Unknown', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: false, excludedInSettings: true } }, { name: "Python 3.12.2 64-bit ('3.12.2': pyenv)", path: '/Users/hello/.pyenv/versions/3.12.2/bin/python', versionInfo: { version: '3.12.2', supportedVersion: true }, envInfo: { envType: 'Pyenv', envName: '3.12.2' }, enablementInfo: { visibleInUI: true, includedInSettings: false, excludedInSettings: false } }, { name: "Python 3.6.15 64-bit ('3.6.15': pyenv)", path: '/Users/hello/.pyenv/versions/3.6.15/bin/python', versionInfo: { version: '3.6.15', supportedVersion: false }, envInfo: { envType: 'Pyenv', envName: '3.6.15' }, enablementInfo: { visibleInUI: true, includedInSettings: false, excludedInSettings: false } }, { name: "Python 3.10.4 ('.venv': venv)", path: '/Users/hello/qa-example-content/.venv/bin/python', versionInfo: { version: '3.10.4', supportedVersion: true }, envInfo: { envType: 'Venv', envName: '.venv' }, enablementInfo: { visibleInUI: true, includedInSettings: false, excludedInSettings: false } }, { name: 'Python 3.10.4 64-bit (custom)', path: '/Users/hello/scratch/3.10.4/bin/python', versionInfo: { version: '3.10.4', supportedVersion: true }, envInfo: { envType: 'Unknown', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: true, excludedInSettings: true } }, { name: 'Python 3.9.6 64-bit', path: '/usr/bin/python3', versionInfo: { version: '3.9.6', supportedVersion: true }, envInfo: { envType: 'Global', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: false, excludedInSettings: true } }, { name: 'Python 3.10.4 64-bit', path: '/usr/local/bin/python3', versionInfo: { version: '3.10.4', supportedVersion: true }, envInfo: { envType: 'Global', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: false, excludedInSettings: true } }, { name: 'Python 3.11.6 64-bit', path: '/usr/local/bin/python3.11', versionInfo: { version: '3.11.6', supportedVersion: true }, envInfo: { envType: 'Global', envName: '' }, enablementInfo: { visibleInUI: false, includedInSettings: false, excludedInSettings: true } } ] 2025-02-24 10:43:17.026 [info] ===================================================================== 2025-02-24 10:43:17.026 [info] ================ [END] PYTHON INTERPRETER DEBUG INFO ================ 2025-02-24 10:43:17.026 [info] ===================================================================== ```
### Release Notes #### New Features - New settings to include additional Python interpreter search directories or exclude Python interpreters from the UI (#3574) #### Bug Fixes - filters out unsupported (<3.8) Python versions from the `Python: Select Interpreter` dropdown (#3740) @isabelizimm ### QA Notes - Please verify these options using both the JS and Native locators. Toggle between the locators by updating setting `python.locator`. - Positron will need to be restarted upon changing the `python.interpreters.include`/`python.interpreters.exclude` settings so that discovery can re-run with the settings applied - If an interpreter path is captured in both options, it will be excluded - If a user has a python install they want to include at path `/1/2/3/4/5/3.10.4/bin/python`, they should use `/1/2/3/4/5` or `/1/2/3/4/5/3.10.4` as the included directory. - Windows users can specify paths with either path separator - Relative paths specified in the options are ignored - If a user starts an interpreter, then excludes or un-includes it and restarts Positron, it will still be selected upon restart - if they shut it down and then restart, it should no longer show --------- Co-authored-by: Isabel Zimmerman <54685329+isabelizimm@users.noreply.github.com> --- extensions/positron-python/package.json | 25 +++ extensions/positron-python/package.nls.json | 3 + .../src/client/common/application/commands.ts | 3 + .../src/client/common/configSettings.ts | 30 +++ .../src/client/common/constants.ts | 3 + .../src/client/common/types.ts | 2 + .../commands/setInterpreter.ts | 35 ++- .../src/client/positron/discoverer.ts | 79 ++++--- .../src/client/positron/extension.ts | 15 +- .../client/positron/interpreterSettings.ts | 211 ++++++++++++++++++ .../src/client/positron/manager.ts | 12 +- .../locators/common/nativePythonFinder.ts | 27 ++- .../lowLevel/posixKnownPathsLocator.ts | 48 ++++ .../lowLevel/userSpecifiedEnvLocator.ts | 132 +++++++++++ .../src/client/pythonEnvironments/index.ts | 7 + .../commands/setInterpreter.unit.test.ts | 18 ++ .../src/test/positron/manager.unit.test.ts | 17 +- .../userSpecifiedEnvLocator.unit.test.ts | 160 +++++++++++++ .../nativePythonFinder.unit.test.ts | 1 + 19 files changed, 788 insertions(+), 40 deletions(-) create mode 100644 extensions/positron-python/src/client/positron/interpreterSettings.ts create mode 100644 extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.ts create mode 100644 extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.unit.test.ts diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index 6b0e612d9b9..35dc61ce136 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -481,6 +481,11 @@ "category": "Python", "command": "python.installJupyter", "title": "%python.command.python.installJupyter.title%" + }, + { + "category": "Python", + "command": "python.interpreters.debugInfo", + "title": "%python.command.python.interpreterDebugInfo.title%" } ], "configuration": { @@ -530,6 +535,26 @@ "scope": "machine-overridable", "type": "string" }, + "python.interpreters.include": { + "default": [], + "markdownDescription": "%python.interpreters.include.markdownDescription%", + "scope": "window", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "python.interpreters.exclude": { + "default": [], + "markdownDescription": "%python.interpreters.exclude.markdownDescription%", + "scope": "window", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "python.envFile": { "default": "${workspaceFolder}/.env", "description": "%python.envFile.description%", diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index a6a66f6e073..b0811f8473d 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -39,6 +39,7 @@ "python.command.python.analysis.restartLanguageServer.title": "Restart Language Server", "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", + "python.command.python.interpreterDebugInfo.title": "Print interpreter debug information to Output", "python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.", "python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project", "python.menu.createNewFile.title": "Python File", @@ -49,6 +50,8 @@ "python.condaPath.description": "Path to the conda executable to use for activation (version 4.4+).", "python.debugger.deprecatedMessage": "This configuration will be deprecated soon. Please replace `python` with `debugpy` to use the new Python Debugger extension.", "python.defaultInterpreterPath.description": "Path to default Python to use when extension loads up for the first time, no longer used once an interpreter is selected for the workspace. See [here](https://aka.ms/AAfekmf) to understand when this is used", + "python.interpreters.include.markdownDescription": "List of folders (absolute paths) to search for Python installations. These folders are searched in addition to the default folders for your operating system.\n\nExample: if you have Python located at `/custom/pythons/3.10.4/bin/python`, add path `/custom/pythons` or `/custom/pythons/3.10.4` to this setting.\n\nNote: If an interpreter is both included via `python.interpreters.include` and excluded via `python.interpreters.exclude`, it will not be displayed in the Positron UI.\n\nRequires a restart to take effect.", + "python.interpreters.exclude.markdownDescription": "List of interpreter paths or folders (absolute paths) to exclude from the available Python installations. These interpreters will not be displayed in the Positron UI.\n\nExample: Add `/usr/bin/python3` to exclude the specific interpreter, or `/opt/homebrew` to exclude all brew-installed Pythons on macOS.\n\nRequires a restart to take effect.", "python.envFile.description": "Absolute path to a file containing environment variable definitions.", "python.experiments.enabled.description": "Enables A/B tests experiments in the Python extension. If enabled, you may get included in proposed enhancements and/or features.", "python.experiments.optInto.description": "List of experiments to opt into. If empty, user is assigned the default experiment groups. See [here](https://github.com/microsoft/vscode-python/wiki/AB-Experiments) for more details.", diff --git a/extensions/positron-python/src/client/common/application/commands.ts b/extensions/positron-python/src/client/common/application/commands.ts index 25abdd2aee9..de432d30d1e 100644 --- a/extensions/positron-python/src/client/common/application/commands.ts +++ b/extensions/positron-python/src/client/common/application/commands.ts @@ -41,6 +41,9 @@ interface ICommandNameWithoutArgumentTypeMapping { [Commands.CreateNewFile]: []; [Commands.ReportIssue]: []; [LSCommands.RestartLS]: []; + // --- Start Positron --- + [Commands.Show_Interpreter_Debug_Info]: []; + // --- End Positron --- } export type AllCommands = keyof ICommandNameArgumentTypeMapping; diff --git a/extensions/positron-python/src/client/common/configSettings.ts b/extensions/positron-python/src/client/common/configSettings.ts index b831e5d9e3c..03ae8682c7d 100644 --- a/extensions/positron-python/src/client/common/configSettings.ts +++ b/extensions/positron-python/src/client/common/configSettings.ts @@ -1,5 +1,9 @@ 'use strict'; +// --- Start Positron --- +/* eslint-disable import/no-duplicates */ +// --- End Positron --- + // eslint-disable-next-line camelcase import * as path from 'path'; import * as fs from 'fs'; @@ -38,6 +42,10 @@ import { SystemVariables } from './variables/systemVariables'; import { getOSType, OSType, isWindows } from './utils/platform'; import { untildify } from './helpers'; +// --- Start Positron --- +import { INTERPRETERS_EXCLUDE_SETTING_KEY, INTERPRETERS_INCLUDE_SETTING_KEY } from './constants'; +// --- End Positron --- + export class PythonSettings implements IPythonSettings { private get onDidChange(): Event { return this.changed.event; @@ -82,6 +90,16 @@ export class PythonSettings implements IPythonSettings { } } + // --- Start Positron --- + public get interpretersInclude(): string[] { + return this._interpretersInclude; + } + + public get interpretersExclude(): string[] { + return this._interpretersExclude; + } + // --- End Positron --- + private static pythonSettings: Map = new Map(); public envFile = ''; @@ -143,6 +161,12 @@ export class PythonSettings implements IPythonSettings { private _defaultInterpreterPath = ''; + // --- Start Positron --- + private _interpretersInclude: string[] = []; + + private _interpretersExclude: string[] = []; + // --- End Positron --- + private readonly workspace: IWorkspaceService; constructor( @@ -310,6 +334,12 @@ export class PythonSettings implements IPythonSettings { // Whether to suppress the banner on startup of the IPython shell this.quietMode = pythonSettings.get('quietMode') === true; + + // User-specified interpreter paths to include in discovery + this._interpretersInclude = pythonSettings.get(INTERPRETERS_INCLUDE_SETTING_KEY) ?? []; + + // User-specified interpreter paths to exclude from available interpreters + this._interpretersExclude = pythonSettings.get(INTERPRETERS_EXCLUDE_SETTING_KEY) ?? []; // --- End Positron --- const autoCompleteSettings = systemVariables.resolveAny( diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index f1b603c7643..cc8206ede32 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -81,6 +81,7 @@ export namespace Commands { export const Is_Conda_Installed = 'python.isCondaInstalled'; export const Get_Conda_Python_Versions = 'python.getCondaPythonVersions'; export const Is_Global_Python = 'python.isGlobalPython'; + export const Show_Interpreter_Debug_Info = 'python.interpreters.debugInfo'; // --- End Positron --- export const InstallJupyter = 'python.installJupyter'; export const InstallPython = 'python.installPython'; @@ -149,6 +150,8 @@ export const UseProposedApi = Symbol('USE_VSC_PROPOSED_API'); // --- Start Positron --- export const IPYKERNEL_VERSION = '>=6.19.1'; export const MINIMUM_PYTHON_VERSION = { major: 3, minor: 8, patch: 0, raw: '3.8.0' } as PythonVersion; +export const INTERPRETERS_INCLUDE_SETTING_KEY = 'interpreters.include'; +export const INTERPRETERS_EXCLUDE_SETTING_KEY = 'interpreters.exclude'; // --- End Positron export * from '../constants'; diff --git a/extensions/positron-python/src/client/common/types.ts b/extensions/positron-python/src/client/common/types.ts index 779a91ca4f8..9b80a1d9cf4 100644 --- a/extensions/positron-python/src/client/common/types.ts +++ b/extensions/positron-python/src/client/common/types.ts @@ -189,6 +189,8 @@ export interface IPythonSettings { readonly languageServerDebug: boolean; readonly languageServerLogLevel: string; readonly quietMode: boolean; + readonly interpretersInclude: string[]; + readonly interpretersExclude: string[]; // --- End Positron --- readonly defaultInterpreterPath: string; readonly REPL: IREPLSettings; diff --git a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 732dc737769..c6302732932 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -1,5 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// --- Start Positron --- +// Disable eslint rules for our import block below. This appears at the top of the file to stop +// auto-formatting tools from reordering the imports. +/* eslint-disable import/no-duplicates */ +// --- End Positron --- 'use strict'; @@ -50,11 +55,12 @@ import { BaseInterpreterSelectorCommand } from './base'; // --- Start Positron --- // eslint-disable-next-line import/order import * as fs from 'fs-extra'; -// eslint-disable-next-line import/no-duplicates import { CreateEnv } from '../../../../common/utils/localize'; import { IPythonRuntimeManager } from '../../../../positron/manager'; import { showErrorMessage } from '../../../../common/vscodeApis/windowApis'; import { traceError } from '../../../../logging'; +import { isVersionSupported, shouldIncludeInterpreter } from '../../../../positron/interpreterSettings'; +import { MINIMUM_PYTHON_VERSION } from '../../../../common/constants'; // --- End Positron --- import { untildify } from '../../../../common/helpers'; import { useEnvExtension } from '../../../../envExt/api.internal'; @@ -283,7 +289,11 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem if (defaultInterpreterPathSuggestion) { suggestions.push(defaultInterpreterPathSuggestion); } - const interpreterSuggestions = this.getSuggestions(resource, filter, params); + + // --- Start Positron --- + // Apply additional filtering to the suggestions by wrapping the filter + const interpreterSuggestions = this.getSuggestions(resource, filterWrapper(filter), params); + // --- End Positron --- this.finalizeItems(interpreterSuggestions, resource, params); suggestions.push(...interpreterSuggestions); return suggestions; @@ -399,7 +409,11 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem const updatedItems = [...items.values()]; const areItemsGrouped = items.find((item) => isSeparatorItem(item)); const env = event.old ?? event.new; - if (filter && event.new && !filter(event.new)) { + + // --- Start Positron --- + // Apply additional filtering to the suggestions by wrapping the filter + if (filterWrapper(filter) && event.new && !filterWrapper(filter)(event.new)) { + // --- End Positron --- event.new = undefined; // Remove envs we're not looking for from the list. } let envIndex = -1; @@ -719,3 +733,18 @@ function getGroup(item: IInterpreterQuickPickItem, workspacePath?: string) { return EnvGroups[item.interpreter.envType]; } } + +// --- Start Positron --- +/** + * Wraps the original filter function to include additional filtering logic. + * If no filter is provided, the returned function will only include the additional filtering logic. + * @param filter The original filter function + * @returns A new filter function that includes the original filter function and the additional filtering logic + */ +function filterWrapper(filter: ((i: PythonEnvironment) => boolean) | undefined) { + return (i: PythonEnvironment) => + (filter ? filter(i) : true) && + shouldIncludeInterpreter(i.path) && + isVersionSupported(i.version, MINIMUM_PYTHON_VERSION); +} +// --- End Positron --- diff --git a/extensions/positron-python/src/client/positron/discoverer.ts b/extensions/positron-python/src/client/positron/discoverer.ts index ff233234561..85a78fb30ab 100644 --- a/extensions/positron-python/src/client/positron/discoverer.ts +++ b/extensions/positron-python/src/client/positron/discoverer.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -12,10 +12,10 @@ import { IInterpreterService } from '../interpreter/contracts'; import { IServiceContainer } from '../ioc/types'; import { traceError, traceInfo } from '../logging'; import { PythonEnvironment } from '../pythonEnvironments/info'; -import { PythonVersion } from '../pythonEnvironments/info/pythonVersion'; import { createPythonRuntimeMetadata } from './runtime'; import { comparePythonVersionDescending } from '../interpreter/configuration/environmentTypeComparer'; import { MINIMUM_PYTHON_VERSION } from '../common/constants'; +import { isVersionSupported, shouldIncludeInterpreter } from './interpreterSettings'; /** * Provides Python language runtime metadata to Positron; called during the @@ -49,12 +49,19 @@ export async function* pythonRuntimeDiscoverer( // Discover Python interpreters let interpreters = interpreterService.getInterpreters(); + + traceInfo(`pythonRuntimeDiscoverer: discovered ${interpreters.length} Python interpreters`); + + // Filter out unsupported and user-excluded interpreters + traceInfo('pythonRuntimeDiscoverer: filtering interpreters'); + interpreters = filterInterpreters(interpreters); + + traceInfo(`pythonRuntimeDiscoverer: ${interpreters.length} Python interpreters remain after filtering`); + // Sort the available interpreters, favoring the recommended interpreter (if one is available) traceInfo('pythonRuntimeDiscoverer: sorting interpreters'); interpreters = sortInterpreters(interpreters, recommendedInterpreter); - traceInfo(`pythonRuntimeDiscoverer: discovered ${interpreters.length} Python interpreters`); - // Recommend Python for the workspace if it contains Python-relevant files let recommendedForWorkspace = await hasFiles([ // Code and notebook files @@ -75,23 +82,19 @@ export async function* pythonRuntimeDiscoverer( // Register each interpreter as a language runtime metadata entry for (const interpreter of interpreters) { try { - if (isVersionSupported(interpreter?.version, MINIMUM_PYTHON_VERSION)) { - const runtime = await createPythonRuntimeMetadata( - interpreter, - serviceContainer, - recommendedForWorkspace, - ); - - // Ensure we only recommend one runtime for the workspace. - recommendedForWorkspace = false; - - traceInfo( - `pythonRuntimeDiscoverer: registering runtime for interpreter ${interpreter.path} with id ${runtime.runtimeId}`, - ); - yield runtime; - } else { - traceInfo(`pythonRuntimeDiscoverer: skipping unsupported interpreter ${interpreter.path}`); - } + const runtime = await createPythonRuntimeMetadata( + interpreter, + serviceContainer, + recommendedForWorkspace, + ); + + // Ensure we only recommend one runtime for the workspace. + recommendedForWorkspace = false; + + traceInfo( + `pythonRuntimeDiscoverer: registering runtime for interpreter ${interpreter.path} with id ${runtime.runtimeId}`, + ); + yield runtime; } catch (err) { traceError( `pythonRuntimeDiscoverer: failed to register runtime for interpreter ${interpreter.path}`, @@ -104,6 +107,32 @@ export async function* pythonRuntimeDiscoverer( } } +/** + * Returns a list of Python interpreters with unsupported and user-excluded interpreters removed. + * @param interpreters The list of Python interpreters to filter. + * @returns A list of Python interpreters that are supported and not user-excluded. + */ +function filterInterpreters(interpreters: PythonEnvironment[]): PythonEnvironment[] { + return interpreters.filter((interpreter) => { + // Check if the interpreter version is supported + const isSupported = isVersionSupported(interpreter.version, MINIMUM_PYTHON_VERSION); + if (!isSupported) { + traceInfo(`pythonRuntimeDiscoverer: filtering out unsupported interpreter ${interpreter.path}`); + return false; + } + + // Check if the interpreter is excluded by the user + const shouldInclude = shouldIncludeInterpreter(interpreter.path); + if (!shouldInclude) { + traceInfo(`pythonRuntimeDiscoverer: filtering out user-excluded interpreter ${interpreter.path}`); + return false; + } + + // Otherwise, keep the interpreter! + return true; + }); +} + // Returns a sorted copy of the array of Python environments, in descending order function sortInterpreters( interpreters: PythonEnvironment[], @@ -130,11 +159,3 @@ async function hasFiles(includes: string[]): Promise { // Exclude node_modules for performance reasons return (await vscode.workspace.findFiles(include, '**/node_modules/**', 1)).length > 0; } - -/** - * Check if a version is supported (i.e. >= the minimum supported version). - * Also returns true if the version could not be determined. - */ -function isVersionSupported(version: PythonVersion | undefined, minimumSupportedVersion: PythonVersion): boolean { - return !version || comparePythonVersionDescending(minimumSupportedVersion, version) >= 0; -} diff --git a/extensions/positron-python/src/client/positron/extension.ts b/extensions/positron-python/src/client/positron/extension.ts index cfbd892e5f1..d3885ca6106 100644 --- a/extensions/positron-python/src/client/positron/extension.ts +++ b/extensions/positron-python/src/client/positron/extension.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. + * Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ @@ -18,6 +18,7 @@ import { Interpreters } from '../common/utils/localize'; import { IApplicationShell } from '../common/application/types'; import { activateAppDetection as activateWebAppDetection } from './webAppContexts'; import { activateWebAppCommands } from './webAppCommands'; +import { printInterpreterDebugInfo } from './interpreterSettings'; export async function activatePositron(serviceContainer: IServiceContainer): Promise { try { @@ -73,6 +74,18 @@ export async function activatePositron(serviceContainer: IServiceContainer): Pro disposables.push( vscode.commands.registerCommand('python.getMinimumPythonVersion', (): string => MINIMUM_PYTHON_VERSION.raw), ); + // Register a command to output information about Python environments. + disposables.push( + vscode.commands.registerCommand(Commands.Show_Interpreter_Debug_Info, async () => { + // Open up the Python Language Pack output channel. + await vscode.commands.executeCommand(Commands.ViewOutput); + + // Log information about the Python environments. + const interpreterService = serviceContainer.get(IInterpreterService); + const interpreters = interpreterService.getInterpreters(); + printInterpreterDebugInfo(interpreters); + }), + ); // Activate detection for web applications activateWebAppDetection(disposables); diff --git a/extensions/positron-python/src/client/positron/interpreterSettings.ts b/extensions/positron-python/src/client/positron/interpreterSettings.ts new file mode 100644 index 00000000000..5c7caf60691 --- /dev/null +++ b/extensions/positron-python/src/client/positron/interpreterSettings.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; + +import { traceInfo, traceVerbose } from '../logging'; +import { getConfiguration } from '../common/vscodeApis/workspaceApis'; +import { arePathsSame, isParentPath } from '../pythonEnvironments/common/externalDependencies'; +import { + INTERPRETERS_EXCLUDE_SETTING_KEY, + INTERPRETERS_INCLUDE_SETTING_KEY, + MINIMUM_PYTHON_VERSION, +} from '../common/constants'; +import { untildify } from '../common/helpers'; +import { PythonEnvironment } from '../pythonEnvironments/info'; +import { PythonVersion } from '../pythonEnvironments/info/pythonVersion'; +import { comparePythonVersionDescending } from '../interpreter/configuration/environmentTypeComparer'; + +/** + * Gets the list of interpreters that the user has explicitly included in the settings. + * Converts aliased paths to absolute paths. Relative paths are not included. + * @returns List of interpreters that the user has explicitly included in the settings. + */ +export function getUserIncludedInterpreters(): string[] { + const interpretersInclude = getConfiguration('python').get(INTERPRETERS_INCLUDE_SETTING_KEY) ?? []; + if (interpretersInclude.length > 0) { + return interpretersInclude + .map((item) => untildify(item)) + .filter((item) => { + if (path.isAbsolute(item)) { + return true; + } + traceInfo(`[shouldIncludeInterpreter]: included interpreter path ${item} is not absolute...ignoring`); + return false; + }); + } + traceVerbose(`[shouldIncludeInterpreter]: No interpreters specified via ${INTERPRETERS_INCLUDE_SETTING_KEY}`); + return []; +} + +/** + * Gets the list of interpreters that the user has explicitly excluded in the settings. + * Converts aliased paths to absolute paths. Relative paths are not included. + * @returns List of interpreters that the user has explicitly excluded in the settings. + */ +export function getUserExcludedInterpreters(): string[] { + const interpretersExclude = getConfiguration('python').get(INTERPRETERS_EXCLUDE_SETTING_KEY) ?? []; + if (interpretersExclude.length > 0) { + return interpretersExclude + .map((item) => untildify(item)) + .filter((item) => { + if (path.isAbsolute(item)) { + return true; + } + traceInfo(`[shouldIncludeInterpreter]: excluded interpreter path ${item} is not absolute...ignoring`); + return false; + }); + } + traceVerbose(`[shouldIncludeInterpreter]: No interpreters specified via ${INTERPRETERS_EXCLUDE_SETTING_KEY}`); + return []; +} + +/** + * Check whether an interpreter should be included in the list of discovered interpreters. + * If an interpreter is both included and excluded via settings, it will be excluded. + * @param interpreterPath The interpreter path to check + * @returns Whether the interpreter should be included in the list of discovered interpreters. + */ +export function shouldIncludeInterpreter(interpreterPath: string): boolean { + // If the settings exclude the interpreter, exclude it. Excluding an interpreter takes + // precedence over including it, so we return right away if the interpreter is excluded. + const userExcluded = userExcludedInterpreter(interpreterPath); + if (userExcluded === true) { + traceInfo( + `[shouldIncludeInterpreter] Interpreter ${interpreterPath} excluded via ${INTERPRETERS_EXCLUDE_SETTING_KEY} setting`, + ); + return false; + } + + // If the settings include the interpreter, include it. + const userIncluded = userIncludedInterpreter(interpreterPath); + if (userIncluded === true) { + traceInfo( + `[shouldIncludeInterpreter] Interpreter ${interpreterPath} included via ${INTERPRETERS_INCLUDE_SETTING_KEY} setting`, + ); + return true; + } + + // If the interpreter is not included or excluded in the settings, include it. + traceVerbose(`[shouldIncludeInterpreter] Interpreter ${interpreterPath} not explicitly included or excluded`); + return true; +} + +/** + * Checks if an interpreter path is included in the user's settings. + * @param interpreterPath The interpreter path to check + * @returns True if the interpreter is included in the user's settings, false if it is not included + * in the user's settings, and undefined if the user has not specified any included interpreters. + */ +function userIncludedInterpreter(interpreterPath: string): boolean | undefined { + const interpretersInclude = getUserIncludedInterpreters(); + if (interpretersInclude.length === 0) { + return undefined; + } + return interpretersInclude.some( + (includePath) => isParentPath(interpreterPath, includePath) || arePathsSame(interpreterPath, includePath), + ); +} + +/** + * Checks if an interpreter path is excluded in the user's settings. + * @param interpreterPath The interpreter path to check + * @returns True if the interpreter is excluded in the user's settings, false if it is not excluded + * in the user's settings, and undefined if the user has not specified any excluded interpreters. + */ +function userExcludedInterpreter(interpreterPath: string): boolean | undefined { + const interpretersExclude = getUserExcludedInterpreters(); + if (interpretersExclude.length === 0) { + return undefined; + } + return interpretersExclude.some( + (excludePath) => isParentPath(interpreterPath, excludePath) || arePathsSame(interpreterPath, excludePath), + ); +} + +/** + * Check if a version is supported (i.e. >= the minimum supported version). + * Also returns true if the version could not be determined. + */ +export function isVersionSupported( + version: PythonVersion | undefined, + minimumSupportedVersion: PythonVersion, +): boolean { + return !version || comparePythonVersionDescending(minimumSupportedVersion, version) >= 0; +} + +/** + * Interface for debug information about a Python interpreter. + */ +interface InterpreterDebugInfo { + name: string; // e.g. 'Python 3.13.1 64-bit' + path: string; + versionInfo: { + version: string; + supportedVersion: boolean; + }; + envInfo: { + envName: string; + envType: string; + }; + enablementInfo: { + visibleInUI: boolean; + includedInSettings: boolean | undefined; + excludedInSettings: boolean | undefined; + }; +} + +/** + * Print debug information about the Python interpreters discovered by the extension. + * @param interpreters The list of Python interpreters discovered by the extension. + */ +export function printInterpreterDebugInfo(interpreters: PythonEnvironment[]): void { + // Construct interpreter setting information + const interpreterSettingInfo = { + defaultInterpreterPath: getConfiguration('python').get('defaultInterpreterPath'), + 'interpreters.include': getUserIncludedInterpreters(), + 'interpreters.exclude': getUserExcludedInterpreters(), + }; + + // Construct debug information about each interpreter + const debugInfo = interpreters + .sort((a, b) => { + // Sort by path and then version descending + const pathCompare = a.path.localeCompare(b.path); + if (pathCompare !== 0) { + return pathCompare; + } + return comparePythonVersionDescending(a.version, b.version); + }) + .map( + (interpreter): InterpreterDebugInfo => ({ + name: interpreter.detailedDisplayName ?? interpreter.displayName ?? 'Python', + path: interpreter.path, + versionInfo: { + version: interpreter.version?.raw ?? 'Unknown', + supportedVersion: isVersionSupported(interpreter.version, MINIMUM_PYTHON_VERSION), + }, + envInfo: { + envType: interpreter.envType, + envName: interpreter.envName ?? '', + }, + enablementInfo: { + visibleInUI: shouldIncludeInterpreter(interpreter.path), + includedInSettings: userIncludedInterpreter(interpreter.path), + excludedInSettings: userExcludedInterpreter(interpreter.path), + }, + }), + ); + + // Print debug information + traceInfo('====================================================================='); + traceInfo('=============== [START] PYTHON INTERPRETER DEBUG INFO ==============='); + traceInfo('====================================================================='); + traceInfo('Python interpreter settings:', interpreterSettingInfo); + traceInfo('Python interpreters discovered:', debugInfo); + traceInfo('====================================================================='); + traceInfo('================ [END] PYTHON INTERPRETER DEBUG INFO ================'); + traceInfo('====================================================================='); +} diff --git a/extensions/positron-python/src/client/positron/manager.ts b/extensions/positron-python/src/client/positron/manager.ts index e1f5aa576c3..9ff528f4598 100644 --- a/extensions/positron-python/src/client/positron/manager.ts +++ b/extensions/positron-python/src/client/positron/manager.ts @@ -25,6 +25,7 @@ import { EXTENSION_ROOT_DIR } from '../common/constants'; import { JupyterKernelSpec } from '../positron-supervisor.d'; import { IEnvironmentVariablesProvider } from '../common/variables/types'; import { checkAndInstallPython } from './extension'; +import { shouldIncludeInterpreter } from './interpreterSettings'; export const IPythonRuntimeManager = Symbol('IPythonRuntimeManager'); @@ -114,10 +115,15 @@ export class PythonRuntimeManager implements IPythonRuntimeManager { * @param runtimeMetadata The metadata for the runtime to register. */ public registerLanguageRuntime(runtime: positron.LanguageRuntimeMetadata): void { - // Save the runtime for later use const extraData = runtime.extraRuntimeData as PythonRuntimeExtraData; - this.registeredPythonRuntimes.set(extraData.pythonPath, runtime); - this.onDidDiscoverRuntimeEmitter.fire(runtime); + // Check if the interpreter should be included in the list of registered runtimes + if (shouldIncludeInterpreter(extraData.pythonPath)) { + // Save the runtime for later use + this.registeredPythonRuntimes.set(extraData.pythonPath, runtime); + this.onDidDiscoverRuntimeEmitter.fire(runtime); + } else { + traceInfo(`Not registering runtime ${extraData.pythonPath} as it is excluded via user settings.`); + } } /** diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index d8e137700f5..5762fb80976 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// --- Start Positron --- +/* eslint-disable import/no-duplicates */ +// --- End Positron --- + import { Disposable, EventEmitter, Event, Uri } from 'vscode'; import * as ch from 'child_process'; import * as path from 'path'; @@ -23,6 +27,11 @@ import { StopWatch } from '../../../../common/utils/stopWatch'; import { untildify } from '../../../../common/helpers'; import { traceError } from '../../../../logging'; +// --- Start Positron --- +import { getUserIncludedInterpreters } from '../../../../positron/interpreterSettings'; +import { traceVerbose } from '../../../../logging'; +// --- End Positron --- + const PYTHON_ENV_TOOLS_PATH = isWindows() ? // --- Start Positron --- // update path to reflect the location of the PET binary @@ -456,14 +465,26 @@ function getEnvironmentDirs(): string[] { */ function getAdditionalEnvDirs(): string[] { const additionalDirs: string[] = []; + + // Add additional dirs to search for Python environments on non-Windows platforms. if (!isWindows()) { // /opt/python is a recommended Python installation location on Posit Workbench. // see: https://docs.posit.co/ide/server-pro/python/installing_python.html additionalDirs.push('/opt/python'); } - // TODO: add user-specified additional directories to include in discovery - // see: https://github.com/posit-dev/positron/issues/3574 - return additionalDirs; + + // Add user-specified Python search directories. + const userIncludedDirs = getUserIncludedInterpreters(); + additionalDirs.push(...userIncludedDirs); + + // Return the list of additional directories. + const uniqueDirs = Array.from(new Set(additionalDirs)); + traceVerbose( + `[getAdditionalEnvDirs] Found ${ + uniqueDirs.length + } additional directories to search for Python environments: ${uniqueDirs.map((dir) => `"${dir}"`).join(', ')}`, + ); + return uniqueDirs; } // --- End Positron --- diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts index daca4b86090..887bc054991 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// --- Start Positron --- +import path from 'path'; +// --- End Positron --- + import * as os from 'os'; import { gte } from 'semver'; import { PythonEnvKind, PythonEnvSource } from '../../info'; @@ -12,6 +16,10 @@ import { isMacDefaultPythonPath } from '../../../common/environmentManagers/macD import { traceError, traceInfo, traceVerbose } from '../../../../logging'; import { StopWatch } from '../../../../common/utils/stopWatch'; +// --- Start Positron --- +import { findInterpretersInDir, looksLikeBasicGlobalPython } from '../../../common/commonUtils'; +// --- End Positron --- + export class PosixKnownPathsLocator extends Locator { public readonly providerId = 'posixKnownPaths'; @@ -34,6 +42,14 @@ export class PosixKnownPathsLocator extends Locator { // the binaries specified in .python-version file in the cwd. We should not be reporting // those binaries as environments. const knownDirs = (await commonPosixBinPaths()).filter((dirname) => !isPyenvShimDir(dirname)); + + // --- Start Positron --- + const additionalDirs = getAdditionalPosixDirs(); + for await (const dir of additionalDirs) { + knownDirs.push(dir); + } + // --- End Positron --- + let pythonBinaries = await getPythonBinFromPosixPaths(knownDirs); traceVerbose(`Found ${pythonBinaries.length} python binaries in posix paths`); @@ -59,3 +75,35 @@ export class PosixKnownPathsLocator extends Locator { return iterator(this.kind); } } + +// --- Start Positron --- +/** + * Gets additional directories to look for Python binaries on Posix systems. + * + * For example, `/opt/python/3.10.4/bin` will be returned if the machine has Python 3.10.4 installed + * in `/opt/python/3.10.4/bin/python`. + * + * See extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts + * `getAdditionalEnvDirs()` for the equivalent handling using the native locator. + * + * @param searchDepth Number of levels of sub-directories to recurse when looking for interpreters. + * Default is 2 levels. + * @returns Paths to Python binaries found in additional locations for Posix systems. + */ +export async function* getAdditionalPosixDirs(searchDepth = 2): AsyncGenerator { + const additionalLocations = [ + // /opt/python is a recommended Python installation location on Posit Workbench. + // see: https://docs.posit.co/ide/server-pro/python/installing_python.html + '/opt/python', + ]; + for (const location of additionalLocations) { + const additionalDirs = findInterpretersInDir(location, searchDepth); + for await (const dir of additionalDirs) { + const { filename } = dir; + if (await looksLikeBasicGlobalPython(filename)) { + yield path.dirname(filename); + } + } + } +} +// --- End Positron --- diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.ts new file mode 100644 index 00000000000..f07ca3dc758 --- /dev/null +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * This file is a custom locator for Python environments that are specified by the user via Python + * setting `python.interpreters.include`. + * + * The implementation follows similar patterns to other locators in the same directory. + * + * See extensions/positron-python/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts + * `getAdditionalEnvDirs()` for the equivalent handling using the native locator. + */ + +import { toLower, uniq, uniqBy } from 'lodash'; +import { chain, iterable } from '../../../../common/utils/async'; +import { getOSType, OSType } from '../../../../common/utils/platform'; +import { PythonEnvKind } from '../../info'; +import { BasicEnvInfo, IPythonEnvsIterator } from '../../locator'; +import { FSWatchingLocator } from './fsWatchingLocator'; +import { findInterpretersInDir, looksLikeBasicVirtualPython } from '../../../common/commonUtils'; +import '../../../../common/extensions'; +import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../../logging'; +import { StopWatch } from '../../../../common/utils/stopWatch'; +import { getUserIncludedInterpreters } from '../../../../positron/interpreterSettings'; +import { isParentPath } from '../../../common/externalDependencies'; + +/** + * Default number of levels of sub-directories to recurse when looking for interpreters. + */ +const DEFAULT_SEARCH_DEPTH = 2; + +/** + * Gets all user-specified directories to look for environments. + */ +async function getUserSpecifiedEnvDirs(): Promise { + const envDirs = getUserIncludedInterpreters(); + return [OSType.Windows, OSType.OSX].includes(getOSType()) ? uniqBy(envDirs, toLower) : uniq(envDirs); +} + +/** + * Return PythonEnvKind.Custom for all environments found by this locator. + * @param _interpreterPath: Absolute path to the interpreter paths. This is not used. + */ +async function getVirtualEnvKind(_interpreterPath: string): Promise { + return PythonEnvKind.Custom; +} + +/** + * Finds and resolves virtual environments created in user-specified locations. + */ +export class UserSpecifiedEnvironmentLocator extends FSWatchingLocator { + public readonly providerId: string = 'user-specified-env'; + + constructor(private readonly searchDepth?: number) { + super(getUserSpecifiedEnvDirs, getVirtualEnvKind, { + // Note detecting kind of virtual env depends on the file structure around the + // executable, so we need to wait before attempting to detect it. However even + // if the type detected is incorrect, it doesn't do any practical harm as kinds + // in this locator are used in the same way (same activation commands etc.) + delayOnCreated: 1000, + }); + } + + protected doIterEnvs(): IPythonEnvsIterator { + // Number of levels of sub-directories to recurse when looking for + // interpreters + const searchDepth = this.searchDepth ?? DEFAULT_SEARCH_DEPTH; + + async function* iterator() { + const stopWatch = new StopWatch(); + traceInfo('[UserSpecifiedEnvironmentLocator] Searching for user-specified environments'); + const envRootDirs = await getUserSpecifiedEnvDirs(); + const envGenerators = envRootDirs.map((envRootDir) => { + async function* generator() { + traceVerbose( + `[UserSpecifiedEnvironmentLocator] Searching for user-specified envs in: ${envRootDir}`, + ); + + const foundPythons: string[] = []; + const executables = findInterpretersInDir(envRootDir, searchDepth, undefined, false); + + for await (const entry of executables) { + const { filename } = entry; + // We only care about python.exe (on windows) and python (on linux/mac) + // Other version like python3.exe or python3.8 are often symlinks to + // python.exe or python in the same directory in the case of virtual + // environments. + if (await looksLikeBasicVirtualPython(entry)) { + // We should extract the kind here to avoid doing is*Environment() + // check multiple times. Those checks are file system heavy and + // we can use the kind to determine this anyway. + const kind = await getVirtualEnvKind(filename); + try { + foundPythons.push(filename); + yield { kind, executablePath: filename, searchLocation: undefined }; + traceVerbose( + `[UserSpecifiedEnvironmentLocator] User-specified Environment: [added] ${filename}`, + ); + } catch (ex) { + traceError( + `[UserSpecifiedEnvironmentLocator] Failed to process environment: ${filename}`, + ex, + ); + } + } else { + traceVerbose( + `[UserSpecifiedEnvironmentLocator] User-specified Environment: [skipped] ${filename}`, + ); + } + } + + // If no environments are found in the directory, log a warning. + if (!foundPythons.find((entry) => isParentPath(entry, envRootDir))) { + traceWarn( + `[UserSpecifiedEnvironmentLocator] No environments found in: ${envRootDir}. The directory may not contain Python installations or is an invalid path.`, + ); + } + } + return generator(); + }); + + yield* iterable(chain(envGenerators)); + traceInfo( + `[UserSpecifiedEnvironmentLocator] Finished searching for user-specified envs: ${stopWatch.elapsedTime} milliseconds`, + ); + } + + return iterator(); + } +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/index.ts b/extensions/positron-python/src/client/pythonEnvironments/index.ts index 57846ffdcef..49ba16d53c0 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/index.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/index.ts @@ -46,6 +46,10 @@ import { createNativeEnvironmentsApi } from './nativeAPI'; import { useEnvExtension } from '../envExt/api.internal'; import { createEnvExtApi } from '../envExt/envExtApi'; +// --- Start Positron --- +import { UserSpecifiedEnvironmentLocator } from './base/locators/lowLevel/userSpecifiedEnvLocator'; +// --- End Positron --- + const PYTHON_ENV_INFO_CACHE_KEY = 'PYTHON_ENV_INFO_CACHEv2'; export function shouldUseNativeLocator(): boolean { @@ -199,6 +203,9 @@ function createNonWorkspaceLocators(ext: ExtensionState): ILocator new ActiveStateLocator(), new GlobalVirtualEnvironmentLocator(), new CustomVirtualEnvironmentLocator(), + // --- Start Positron --- + new UserSpecifiedEnvironmentLocator(), + // --- End Positron --- ); if (getOSType() === OSType.Windows) { diff --git a/extensions/positron-python/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts b/extensions/positron-python/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts index 9d417ab1847..6047fc6ffab 100644 --- a/extensions/positron-python/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts +++ b/extensions/positron-python/src/test/configuration/interpreterSelector/commands/setInterpreter.unit.test.ts @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// --- Start Positron --- +/* eslint-disable import/no-duplicates */ +/* eslint-disable import/order */ +// --- End Positron --- + import * as assert from 'assert'; import { expect } from 'chai'; import * as path from 'path'; @@ -51,7 +56,9 @@ import { untildify } from '../../../../client/common/helpers'; import * as extapi from '../../../../client/envExt/api.internal'; // --- Start Positron --- import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; import { IPythonRuntimeManager } from '../../../../client/positron/manager'; +import { WorkspaceConfiguration } from 'vscode'; // --- End Positron --- type TelemetryEventType = { eventName: EventName; properties: unknown }; @@ -68,6 +75,8 @@ suite('Set Interpreter Command', () => { let multiStepInputFactory: TypeMoq.IMock; // --- Start Positron --- let pythonRuntimeManager: TypeMoq.IMock; + let workspaceConfig: TypeMoq.IMock; + let getConfigurationStub: sinon.SinonStub; // --- End Positron --- let interpreterService: IInterpreterService; let useEnvExtensionStub: sinon.SinonStub; @@ -93,6 +102,15 @@ suite('Set Interpreter Command', () => { pythonRuntimeManager .setup((p) => p.selectLanguageRuntimeFromPath(TypeMoq.It.isAny())) .returns(() => Promise.resolve()); + workspaceConfig = TypeMoq.Mock.ofType(); + + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getConfigurationStub.callsFake((section?: string) => { + if (section === 'python') { + return workspaceConfig.object; + } + return undefined; + }); // --- End Positron --- workspace = TypeMoq.Mock.ofType(); diff --git a/extensions/positron-python/src/test/positron/manager.unit.test.ts b/extensions/positron-python/src/test/positron/manager.unit.test.ts index 214892eba9b..247e75ead5f 100644 --- a/extensions/positron-python/src/test/positron/manager.unit.test.ts +++ b/extensions/positron-python/src/test/positron/manager.unit.test.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -10,9 +10,11 @@ import * as positron from 'positron'; import * as sinon from 'sinon'; import { verify } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; +import { WorkspaceConfiguration } from 'vscode'; import * as fs from '../../client/common/platform/fs-paths'; import * as runtime from '../../client/positron/runtime'; import * as session from '../../client/positron/session'; +import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../client/common/variables/types'; import { IConfigurationService, IDisposable } from '../../client/common/types'; import { IServiceContainer } from '../../client/ioc/types'; @@ -31,6 +33,10 @@ suite('Python runtime manager', () => { let envVarsProvider: TypeMoq.IMock; let interpreterService: TypeMoq.IMock; let serviceContainer: TypeMoq.IMock; + let workspaceConfig: TypeMoq.IMock; + + let getConfigurationStub: sinon.SinonStub; + let pythonRuntimeManager: PythonRuntimeManager; let disposables: IDisposable[]; @@ -41,6 +47,7 @@ suite('Python runtime manager', () => { envVarsProvider = createTypeMoq(); interpreterService = createTypeMoq(); serviceContainer = createTypeMoq(); + workspaceConfig = createTypeMoq(); runtimeMetadata.setup((r) => r.runtimeId).returns(() => 'runtimeId'); runtimeMetadata.setup((r) => r.extraRuntimeData).returns(() => ({ pythonPath })); @@ -53,6 +60,14 @@ suite('Python runtime manager', () => { serviceContainer.setup((s) => s.get(IEnvironmentVariablesProvider)).returns(() => envVarsProvider.object); serviceContainer.setup((s) => s.get(IInterpreterService)).returns(() => interpreterService.object); + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getConfigurationStub.callsFake((section?: string, _scope?: any) => { + if (section === 'python') { + return workspaceConfig.object; + } + return undefined; + }); + pythonRuntimeManager = new PythonRuntimeManager(serviceContainer.object, interpreterService.object); disposables = []; }); diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.unit.test.ts new file mode 100644 index 00000000000..3494b7d797f --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator.unit.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable consistent-return */ + +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as TypeMoq from 'typemoq'; +import { WorkspaceConfiguration } from 'vscode'; +import * as fs from '../../../../../client/common/platform/fs-paths'; +import * as workspaceApis from '../../../../../client/common/vscodeApis/workspaceApis'; +import { PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info'; +import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils'; +import { UserSpecifiedEnvironmentLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/userSpecifiedEnvLocator'; +import { createBasicEnv } from '../../common'; +import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; +import { assertBasicEnvsEqual } from '../envTestUtils'; +import { createTypeMoq } from '../../../../mocks/helper'; +import { INTERPRETERS_INCLUDE_SETTING_KEY } from '../../../../../client/common/constants'; +import { getOSType, OSType } from '../../../../common'; + +/** + * Helper class to create fake executables for the user specified environments locator. + */ +class UserSpecifiedEnvs { + /** + * The list of executables that have been created. + */ + private _executables: string[] = []; + + /** + * Constructor. + * @param root The root directory where the executables will be created + */ + constructor(private readonly root: string) {} + + /** + * Creates a fake executable at the specified path. The path can be relative to the root directory. + * @param interpreterPath The path to the interpreter to create, including the executable name. + * @returns The full path to the created executable. + */ + public async create(interpreterPath: string): Promise { + const filePath = path.isAbsolute(interpreterPath) ? interpreterPath : path.join(this.root, interpreterPath); + + try { + await fs.createFile(filePath); + } catch (err) { + throw new Error(`Failed to create executable ${interpreterPath} at ${filePath}, Error: ${err}`); + } + + // Add the executable to the list of created executables so that they can be cleaned up later. + this._executables.push(filePath); + return filePath; + } + + get executables(): string[] { + return this._executables; + } +} + +const customWindowsEnvs = [ + 'my\\custom\\dir\\for\\pythons\\python-main\\python.exe', + 'another\\dir\\for\\pythons\\python.exe', +]; + +const customPosixEnvs = ['my/custom/dir/for/pythons/python-main/bin/python', 'another/dir/for/pythons/python']; + +suite('UserSpecifiedEnvironment Locator', () => { + const userSpecifiedEnvsRoot = path.join(TEST_LAYOUT_ROOT, 'userSpecifiedEnvs', 'envs'); + const userSpecifiedEnvs = new UserSpecifiedEnvs(userSpecifiedEnvsRoot); + let locator: UserSpecifiedEnvironmentLocator; + let pythonConfig: TypeMoq.IMock; + let getConfigurationStub: sinon.SinonStub; + + // Setup before all tests + suiteSetup(async () => { + // Set up the python configuration settings mocks/stubs + pythonConfig = createTypeMoq(); + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getConfigurationStub.callsFake((section?: string) => { + if (section === 'python') { + return pythonConfig.object; + } + return undefined; + }); + + // Create the fake custom environments for the appropriate OS + if (getOSType() === OSType.Windows) { + await Promise.all(customWindowsEnvs.map((env) => userSpecifiedEnvs.create(env))); + } else { + await Promise.all(customPosixEnvs.map((env) => userSpecifiedEnvs.create(env))); + } + }); + + // Setup before each test + setup(async () => { + locator = new UserSpecifiedEnvironmentLocator(); + }); + + // Teardown after each test + teardown(async () => { + await locator.dispose(); + }); + + // Teardown after all tests + suiteTeardown(async () => { + // Remove the fake executables that were created + await fs.rmdir(userSpecifiedEnvsRoot, { recursive: true }); + + sinon.restore(); + }); + + test('iterEnvs(): Windows', async function () { + // Skip this test if the OS is not Windows + if (getOSType() !== OSType.Windows) { + return this.skip(); + } + + // Configure the user setting to include custom directories + pythonConfig + .setup((p) => p.get(INTERPRETERS_INCLUDE_SETTING_KEY)) + .returns(() => [ + `${userSpecifiedEnvsRoot}\\my\\custom\\dir\\for\\pythons`, + `${userSpecifiedEnvsRoot}\\another\\dir\\for\\pythons`, + ]); + + // These are the expected environments that should be located + const expectedEnvs = userSpecifiedEnvs.executables.map((e: string) => createBasicEnv(PythonEnvKind.Custom, e)); + + // Locate the environments and compare them to the expected environments + const iterator = locator.iterEnvs(); + const actualEnvs = await getEnvs(iterator); + assertBasicEnvsEqual(actualEnvs, expectedEnvs); + }); + + test('iterEnvs(): Non-Windows', async function () { + // Skip this test on Windows + if (getOSType() === OSType.Windows) { + return this.skip(); + } + + // Configure the user setting to include custom directories + pythonConfig + .setup((p) => p.get(INTERPRETERS_INCLUDE_SETTING_KEY)) + .returns(() => [ + `${userSpecifiedEnvsRoot}/my/custom/dir/for/pythons`, + `${userSpecifiedEnvsRoot}/another/dir/for/pythons`, + ]); + + // These are the expected environments that should be located + const expectedEnvs = userSpecifiedEnvs.executables.map((e: string) => createBasicEnv(PythonEnvKind.Custom, e)); + + // Locate the environments and compare them to the expected environments + const iterator = locator.iterEnvs(); + const actualEnvs = await getEnvs(iterator); + assertBasicEnvsEqual(actualEnvs, expectedEnvs); + }); +}); diff --git a/extensions/positron-python/src/test/pythonEnvironments/nativePythonFinder.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/nativePythonFinder.unit.test.ts index 40a001accaa..7c873e68bdd 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/nativePythonFinder.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/nativePythonFinder.unit.test.ts @@ -17,6 +17,7 @@ import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis'; // --- Start Positron --- // re-enable when Native Finder is used for discovery +// TODO: add test for python.interpreters.include here once we switch to Native Finder suite.skip('Native Python Finder', () => { // --- Find Positron --- let finder: NativePythonFinder;