Skip to content

Commit

Permalink
add positron.r.interpreters.exclude setting to exclude R installati…
Browse files Browse the repository at this point in the history
…on paths (#6472)

### Summary

- addresses #6205
- adds a new setting `positron.r.interpreters.exclude` which allows the
user to specify R installation binary paths or directories containing R
installations that should be excluded from the UI in Positron

#### Settings UI

<img width="783" alt="image"
src="https://github.com/user-attachments/assets/af5a1fb3-6f2a-49a9-b2a8-24ba4023cef0"
/>


#### Settings JSON

```json
    "positron.r.interpreters.exclude": [
        "/opt/local"
    ]
```

#### R Language Pack Output

```js
...
2025-02-25 10:11:26.178 [info] User-specified R binaries:
[
  "/opt/local/R/4.3-arm64/Resources/bin/R"
]
...
2025-02-25 10:11:26.178 [info] Candidate R binary at /opt/local/R/4.3-arm64/Resources/bin/R
2025-02-25 10:11:26.178 [info] User-specified R installation paths to exclude:
[
  "/opt/local"
]
2025-02-25 10:11:26.178 [info] User has excluded R installation at /opt/local/R/4.3-arm64/Resources/bin/R
2025-02-25 10:11:26.178 [info] R installation discovered: {
  "usable": false,
  "supported": true,
  "reasonDiscovered": [
    "User-specified location",
    "Found in a conventional location for R binaries installed on a server"
  ],
  "reasonRejected": "Installation path was excluded via settings",
  "binpath": "/opt/local/R/4.3-arm64/Resources/bin/R",
  "homepath": "/Library/Frameworks/R.framework/Versions/4.3-arm64/Resources",
  "semVersion": {
    "options": {},
    "loose": false,
    "includePrerelease": false,
    "raw": "4.3.3",
    "major": 4,
    "minor": 3,
    "patch": 3,
    "prerelease": [],
    "build": [],
    "version": "4.3.3"
  },
  "version": "4.3.3",
  "arch": "arm64",
  "current": false,
  "orthogonal": true
}
...
2025-02-25 10:11:26.178 [info] Filtering out /opt/local/R/4.3-arm64/Resources/bin/R, reason: Installation path was excluded via settings.
2025-02-25 10:11:26.178 [warning] Some discovered R installations are unusable by Positron.
2025-02-25 10:11:26.178 [warning] Learn more about R discovery at https://positron.posit.co/r-installations
```


### Release Notes

#### New Features

- New setting to exclude R installation paths from the UI (#6205)

#### Bug Fixes

- N/A


### QA Notes

- please test with and without the `positron.r.customBinaries` and
`positron.r.customRootFolders` settings which allow "includes" to be
specified
- see
https://positron.posit.co/r-installations.html#customizing-r-discovery
for more info on these options
- Positron will need to be restarted upon changing the settings so that
discovery can re-run with the settings applied
- excluding interpreters via `positron.r.interpreters.exclude` will take
precedence over includes with `positron.r.customBinaries` or
`positron.r.customRootFolders`
- Relative paths specified in the options are ignored
  • Loading branch information
sharon-wang authored Feb 26, 2025
1 parent eb508f9 commit 2a5c332
Show file tree
Hide file tree
Showing 9 changed files with 150 additions and 29 deletions.
3 changes: 3 additions & 0 deletions extensions/positron-python/src/client/common/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export function isNotInstalledError(error: Error): boolean {
return errorObj.code === 'ENOENT' || errorObj.code === 127 || isModuleNoInstalledError;
}

// --- Start Positron ---
// Please port any updates to this function to the copy in extensions/positron-r/src/path-utils.ts!
// --- End Positron ---
export function untildify(path: string): string {
return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ 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.
* Gets the list of interpreters 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.
* @returns List of interpreters included in the settings.
*/
export function getUserIncludedInterpreters(): string[] {
export function getIncludedInterpreters(): string[] {
const interpretersInclude = getConfiguration('python').get<string[]>(INTERPRETERS_INCLUDE_SETTING_KEY) ?? [];
if (interpretersInclude.length > 0) {
return interpretersInclude
Expand All @@ -41,11 +41,11 @@ export function getUserIncludedInterpreters(): string[] {
}

/**
* Gets the list of interpreters that the user has explicitly excluded in the settings.
* Gets the list of interpreters 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.
* @returns List of interpreters excluded in the settings.
*/
export function getUserExcludedInterpreters(): string[] {
export function getExcludedInterpreters(): string[] {
const interpretersExclude = getConfiguration('python').get<string[]>(INTERPRETERS_EXCLUDE_SETTING_KEY) ?? [];
if (interpretersExclude.length > 0) {
return interpretersExclude
Expand All @@ -71,17 +71,17 @@ export function getUserExcludedInterpreters(): string[] {
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) {
const excluded = isExcludedInterpreter(interpreterPath);
if (excluded === 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) {
const included = isIncludedInterpreter(interpreterPath);
if (included === true) {
traceInfo(
`[shouldIncludeInterpreter] Interpreter ${interpreterPath} included via ${INTERPRETERS_INCLUDE_SETTING_KEY} setting`,
);
Expand All @@ -94,13 +94,13 @@ export function shouldIncludeInterpreter(interpreterPath: string): boolean {
}

/**
* Checks if an interpreter path is included in the user's settings.
* Checks if an interpreter path is included in the 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.
* @returns True if the interpreter is included in the settings, false if it is not included
* in the settings, and undefined if included interpreters have not been specified.
*/
function userIncludedInterpreter(interpreterPath: string): boolean | undefined {
const interpretersInclude = getUserIncludedInterpreters();
function isIncludedInterpreter(interpreterPath: string): boolean | undefined {
const interpretersInclude = getIncludedInterpreters();
if (interpretersInclude.length === 0) {
return undefined;
}
Expand All @@ -110,13 +110,13 @@ function userIncludedInterpreter(interpreterPath: string): boolean | undefined {
}

/**
* Checks if an interpreter path is excluded in the user's settings.
* Checks if an interpreter path is excluded in the 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.
* @returns True if the interpreter is excluded in the settings, false if it is not excluded
* in the settings, and undefined if excluded interpreters have not been specified.
*/
function userExcludedInterpreter(interpreterPath: string): boolean | undefined {
const interpretersExclude = getUserExcludedInterpreters();
function isExcludedInterpreter(interpreterPath: string): boolean | undefined {
const interpretersExclude = getExcludedInterpreters();
if (interpretersExclude.length === 0) {
return undefined;
}
Expand Down Expand Up @@ -165,8 +165,8 @@ export function printInterpreterDebugInfo(interpreters: PythonEnvironment[]): vo
// Construct interpreter setting information
const interpreterSettingInfo = {
defaultInterpreterPath: getConfiguration('python').get<string>('defaultInterpreterPath'),
'interpreters.include': getUserIncludedInterpreters(),
'interpreters.exclude': getUserExcludedInterpreters(),
'interpreters.include': getIncludedInterpreters(),
'interpreters.exclude': getExcludedInterpreters(),
};

// Construct debug information about each interpreter
Expand All @@ -193,8 +193,8 @@ export function printInterpreterDebugInfo(interpreters: PythonEnvironment[]): vo
},
enablementInfo: {
visibleInUI: shouldIncludeInterpreter(interpreter.path),
includedInSettings: userIncludedInterpreter(interpreter.path),
excludedInSettings: userExcludedInterpreter(interpreter.path),
includedInSettings: isIncludedInterpreter(interpreter.path),
excludedInSettings: isExcludedInterpreter(interpreter.path),
},
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { untildify } from '../../../../common/helpers';
import { traceError } from '../../../../logging';

// --- Start Positron ---
import { getUserIncludedInterpreters } from '../../../../positron/interpreterSettings';
import { getIncludedInterpreters } from '../../../../positron/interpreterSettings';
import { traceVerbose } from '../../../../logging';
// --- End Positron ---

Expand Down Expand Up @@ -474,7 +474,7 @@ function getAdditionalEnvDirs(): string[] {
}

// Add user-specified Python search directories.
const userIncludedDirs = getUserIncludedInterpreters();
const userIncludedDirs = getIncludedInterpreters();
additionalDirs.push(...userIncludedDirs);

// Return the list of additional directories.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { findInterpretersInDir, looksLikeBasicVirtualPython } from '../../../com
import '../../../../common/extensions';
import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../../logging';
import { StopWatch } from '../../../../common/utils/stopWatch';
import { getUserIncludedInterpreters } from '../../../../positron/interpreterSettings';
import { getIncludedInterpreters } from '../../../../positron/interpreterSettings';
import { isParentPath } from '../../../common/externalDependencies';

/**
Expand All @@ -35,7 +35,7 @@ const DEFAULT_SEARCH_DEPTH = 2;
* Gets all user-specified directories to look for environments.
*/
async function getUserSpecifiedEnvDirs(): Promise<string[]> {
const envDirs = getUserIncludedInterpreters();
const envDirs = getIncludedInterpreters();
return [OSType.Windows, OSType.OSX].includes(getOSType()) ? uniqBy(envDirs, toLower) : uniq(envDirs);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export function readFileSync(filePath: string): string {
* @param filePath File path to check for
* @param parentPath The potential parent path to check for
*/
// --- Start Positron ---
// Please port any updates to this function to the copy in extensions/positron-r/src/path-utils.ts!
// --- End Positron ---
export function isParentPath(filePath: string, parentPath: string): boolean {
if (!parentPath.endsWith(path.sep)) {
parentPath += path.sep;
Expand All @@ -96,10 +99,16 @@ export function resolvePath(filename: string): string {
return path.resolve(filename);
}

// --- Start Positron ---
// Please port any updates to this function to the copy in extensions/positron-r/src/path-utils.ts!
// --- End Positron ---
export function normCasePath(filePath: string): string {
return getOSType() === OSType.Windows ? path.normalize(filePath).toUpperCase() : path.normalize(filePath);
}

// --- Start Positron ---
// Please port any updates to this function to the copy in extensions/positron-r/src/path-utils.ts!
// --- End Positron ---
export function arePathsSame(path1: string, path2: string): boolean {
return normCasePath(path1) === normCasePath(path2);
}
Expand Down
9 changes: 9 additions & 0 deletions extensions/positron-r/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,15 @@
"default": [],
"markdownDescription": "%r.configuration.customBinaries.markdownDescription%"
},
"positron.r.interpreters.exclude": {
"scope": "window",
"type": "array",
"items": {
"type": "string"
},
"default": [],
"markdownDescription": "%r.configuration.interpreters.exclude.markdownDescription%"
},
"positron.r.kernel.path": {
"scope": "window",
"type": "string",
Expand Down
1 change: 1 addition & 0 deletions extensions/positron-r/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"r.configuration.title-dev": "Advanced",
"r.configuration.customRootFolders.markdownDescription": "List of additional folders to search for R installations. These folders are searched after and in the same way as the default folder for your operating system (e.g. `C:/Program Files/R` on Windows).",
"r.configuration.customBinaries.markdownDescription": "List of additional R binaries. If you want to use an R installation that is not automatically discovered, provide the path to its binary here. For example, on Windows this might look like `C:/some/unusual/location/R-4.4.1/bin/x64/R.exe`.",
"r.configuration.interpreters.exclude.markdownDescription": "List of absolute paths to R binaries or folders containing R binaries to exclude from the available R installations. These interpreters will not be displayed in the Positron UI.\n\nExample: On Linux or Mac, add `/custom/location/R/4.3.0/bin/R` to exclude the specific installation, or `/custom/location` to exclude any R installations within the directory.\n\nRequires a restart to take effect.",
"r.configuration.kernelPath.description": "Path on disk to the ARK kernel executable; use this to override the default (embedded) kernel. Note that this is not the path to R.",
"r.configuration.tracing.description": "Traces the communication between VS Code and the language server",
"r.configuration.tracing.off.description": "No tracing.",
Expand Down
44 changes: 44 additions & 0 deletions extensions/positron-r/src/path-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import * as path from 'path';
import * as os from 'os';

/**
* Returns true if given file path exists within the given parent directory, false otherwise.
* Copied from the function of the same name in extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts.
* @param filePath File path to check for
* @param parentPath The potential parent path to check for
*/
export function isParentPath(filePath: string, parentPath: string): boolean {
if (!parentPath.endsWith(path.sep)) {
parentPath += path.sep;
}
if (!filePath.endsWith(path.sep)) {
filePath += path.sep;
}
return normCasePath(filePath).startsWith(normCasePath(parentPath));
}

/**
* Adapted from the function of the same name in extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts.
*/
export function normCasePath(filePath: string): string {
return os.platform() === 'win32' ? path.normalize(filePath).toUpperCase() : path.normalize(filePath);
}

/**
* Copied from the function of the same name in extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts.
*/
export function arePathsSame(path1: string, path2: string): boolean {
return normCasePath(path1) === normCasePath(path2);
}

/**
* Copied from the function of the same name in extensions/positron-python/src/client/common/helpers.ts.
*/
export function untildify(path: string): string {
return path.replace(/^~($|\/|\\)/, `${os.homedir()}$1`);
}
57 changes: 56 additions & 1 deletion extensions/positron-r/src/r-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import * as semver from 'semver';
import * as path from 'path';
import * as fs from 'fs';
import * as vscode from 'vscode';
import { extractValue, readLines, removeSurroundingQuotes } from './util';
import { LOGGER } from './extension';
import { MINIMUM_R_VERSION } from './constants';
import { arePathsSame, isParentPath, untildify } from './path-utils';

/**
* Extra metadata included in the LanguageRuntimeMetadata for R installations.
Expand Down Expand Up @@ -56,7 +58,8 @@ export enum ReasonDiscovered {
export enum ReasonRejected {
invalid = "invalid",
unsupported = "unsupported",
nonOrthogonal = "nonOrthogonal"
nonOrthogonal = "nonOrthogonal",
excluded = "excluded",
}

export function friendlyReason(reason: ReasonDiscovered | ReasonRejected | null): string {
Expand Down Expand Up @@ -85,6 +88,8 @@ export function friendlyReason(reason: ReasonDiscovered | ReasonRejected | null)
return `Unsupported version, i.e. version is less than ${MINIMUM_R_VERSION}`;
case ReasonRejected.nonOrthogonal:
return 'Non-orthogonal installation that is also not the current version';
case ReasonRejected.excluded:
return 'Installation path was excluded via settings';
}
}

Expand Down Expand Up @@ -191,6 +196,14 @@ export class RInstallation {
this.usable = this.current || this.orthogonal;
if (!this.usable) {
this.reasonRejected = ReasonRejected.nonOrthogonal;
} else {
// Check if this installation has been excluded via settings
const excluded = isExcludedInstallation(this.binpath);
if (excluded) {
LOGGER.info(`R installation excluded via settings: ${this.binpath}`);
this.reasonRejected = ReasonRejected.excluded;
this.usable = false;
}
}
} else {
this.reasonRejected = ReasonRejected.unsupported;
Expand Down Expand Up @@ -283,3 +296,45 @@ function getRHomePathWindows(binpath: string): string | undefined {
}

}

/**
* Gets the list of R installations excluded via settings.
* Converts aliased paths to absolute paths. Relative paths are ignored.
* @returns List of installation paths to exclude.
*/
function getExcludedInstallations(): string[] {
const config = vscode.workspace.getConfiguration('positron.r');
const interpretersExclude = config.get<string[]>('interpreters.exclude') ?? [];
if (interpretersExclude.length > 0) {
const excludedPaths = interpretersExclude
.map((item) => untildify(item))
.filter((item) => {
if (path.isAbsolute(item)) {
return true;
}
LOGGER.info(`R installation path to exclude ${item} is not absolute...ignoring`);
return false;
});
const formattedPaths = JSON.stringify(excludedPaths, null, 2);
LOGGER.info(` R installation paths to exclude:\n${formattedPaths}`);
return excludedPaths;
}
LOGGER.debug('No installation paths specified to exclude via settings');
return [];
}

/**
* Checks if the given binary path is excluded via settings.
* @param binpath The binary path to check
* @returns True if the binary path is excluded, false if it is not excluded, and undefined if the
* no exclusions have been specified.
*/
function isExcludedInstallation(binpath: string): boolean | undefined {
const excludedInstallations = getExcludedInstallations();
if (excludedInstallations.length === 0) {
return undefined;
}
return excludedInstallations.some(
excluded => isParentPath(binpath, excluded) || arePathsSame(binpath, excluded)
);
}

0 comments on commit 2a5c332

Please sign in to comment.