Skip to content

Commit b00f30b

Browse files
jmcpherswolfv
andauthored
Add support for discovering, and running, R in Pixi environments (#11026)
Replaces #10995, and addresses #3724. #### New Features - Experimental support for Pixi R installations in Positron; enable in Settings. (#3724) #### Bug Fixes - N/A ### QA Notes This change should be tested on Windows and at least one other OS, since it has a fair bit of Windows specific code, and it also refactors some Conda code so it's worth making sure that still works, too. --------- Co-authored-by: Wolf Vollprecht <[email protected]>
1 parent 6f2b9ca commit b00f30b

File tree

8 files changed

+449
-33
lines changed

8 files changed

+449
-33
lines changed

extensions/positron-r/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,16 @@
286286
],
287287
"markdownDescription": "%r.configuration.interpreters.condaDiscovery.markdownDescription%"
288288
},
289+
"positron.r.interpreters.pixiDiscovery": {
290+
"scope": "resource",
291+
"type": "boolean",
292+
"default": false,
293+
"tags": [
294+
"interpreterSettings",
295+
"experimental"
296+
],
297+
"markdownDescription": "%r.configuration.interpreters.pixiDiscovery.markdownDescription%"
298+
},
289299
"positron.r.kernel.path": {
290300
"scope": "window",
291301
"type": "string",

extensions/positron-r/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"r.configuration.interpreters.override.markdownDescription": "List of absolute paths to R binaries or folders containing R installations to override the list of available R interpreters. Only the interpreters found at the specified paths will be displayed in the Positron UI.\n\nThis setting takes precedence over the `#positron.r.customBinaries#`, `#positron.r.customRootFolders#`, and `#positron.r.interpreters.exclude#` settings.\n\nExample: On Linux or Mac, add `/custom/location/R/4.3.0/bin/R` to include only this specific installation, or `/custom/location` to include only R installations found within the directory.\n\nRequires a restart to take effect.",
4040
"r.configuration.interpreters.default.markdownDescription": "Absolute path to the default R binary to use for new workspaces. This setting no longer applies once you select an R interpreter for the workspace.\n\nExample: On Linux, add `/custom/location/R/4.3.0/bin/R` to set the default interpreter to the specific R binary.\n\nRequires a restart to take effect.",
4141
"r.configuration.interpreters.condaDiscovery.markdownDescription": "Enable discovery of R installations within conda/mamba environments. Support for conda environments in Positron is experimental and may not work correctly in all cases. When enabled, R installations found in conda environments will be available for selection, and the conda environment will be automatically activated when starting an R console.\n\nRequires a restart to take effect.",
42+
"r.configuration.interpreters.pixiDiscovery.markdownDescription": "Enable discovery of R installations within Pixi environments. Pixi is a package manager that uses conda packages with project-local environments stored in `.pixi/envs/`. When enabled, R installations found in Pixi environments within open workspaces will be available for selection, and the Pixi environment will be automatically activated when starting an R console.\n\nRequires a restart to take effect.",
4243
"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.",
4344
"r.configuration.tracing.description": "Traces the communication between VS Code and the language server",
4445
"r.configuration.tracing.off.description": "No tracing.",

extensions/positron-r/src/kernel-spec.ts

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { JupyterKernelSpec } from './positron-supervisor';
1313
import { getArkKernelPath } from './kernel';
1414
import { EXTENSION_ROOT_DIR } from './constants';
1515
import { findCondaExe } from './provider-conda';
16+
import { PackagerMetadata, isPixiMetadata, isCondaMetadata } from './r-installation';
17+
import { findPixiExe } from './provider-pixi';
1618
import { LOGGER } from './extension';
1719

1820
/**
@@ -62,11 +64,12 @@ function setSpeculativeCondaEnvVars(env: Record<string, string>, envPath: string
6264
* variables are used instead.
6365
*
6466
* @param env The environment record to populate with conda variables
67+
* @param rBinaryPath The R binary path to add to PATH
6568
* @param envPath The path to the conda environment
6669
* @param envName The name of the conda environment
6770
* @returns A promise that resolves when activation is complete
6871
*/
69-
async function captureCondaEnvVarsWindows(env: Record<string, string>, envPath: string, envName: string): Promise<void> {
72+
async function captureCondaEnvVarsWindows(env: Record<string, string>, rBinaryPath: string, envPath: string, envName: string): Promise<void> {
7073
const condaExe = findCondaExe(envPath);
7174
if (!condaExe) {
7275
LOGGER.error('Could not find conda.exe for environment:', envPath);
@@ -109,9 +112,20 @@ async function captureCondaEnvVarsWindows(env: Record<string, string>, envPath:
109112
LOGGER.trace(`Skipping line without '=': ${line}`);
110113
continue;
111114
}
112-
const key = trimmed.substring(0, eqIndex).trim();
113-
const value = trimmed.substring(eqIndex + 1).trim();
114-
env[key] = value;
115+
let envKey = trimmed.substring(0, eqIndex).trim();
116+
let envValue = trimmed.substring(eqIndex + 1).trim();
117+
if (process.platform === 'win32') {
118+
// On Windows, add the R binary path to PATH, if it is not already present
119+
envKey = envKey.toUpperCase();
120+
if (rBinaryPath &&
121+
process.platform === 'win32' &&
122+
envKey === 'PATH' &&
123+
!envValue.includes(path.dirname(rBinaryPath))) {
124+
envValue = path.dirname(rBinaryPath) + ';' + envValue;
125+
}
126+
}
127+
LOGGER.trace(`Set ${envKey}=${envValue}`);
128+
env[envKey] = envValue;
115129
}
116130
} else {
117131
throw new Error(`Activation script not found at ${scriptPath}`);
@@ -165,6 +179,117 @@ async function captureCondaEnvVarsWindows(env: Record<string, string>, envPath:
165179
await activationPromise;
166180
}
167181

182+
/**
183+
* JSON output format from `pixi shell-hook --json`
184+
*/
185+
interface PixiShellHookJson {
186+
environment_variables: Record<string, string>;
187+
}
188+
189+
/**
190+
* Capture Pixi environment variables by running `pixi shell-hook --json` and parsing
191+
* the JSON output. This is more reliable than parsing shell scripts.
192+
*
193+
* @param env The environment record to populate with pixi variables
194+
* @param manifestPath The path to the pixi manifest file (pixi.toml or pyproject.toml)
195+
* @param envName The name of the pixi environment
196+
* @returns A promise that resolves when activation is complete
197+
*/
198+
async function capturePixiEnvVars(
199+
env: Record<string, string>,
200+
rBinaryPath: string,
201+
manifestPath: string,
202+
envName?: string
203+
): Promise<void> {
204+
const pixiExe = await findPixiExe();
205+
if (!pixiExe) {
206+
// This shouldn't happen since we discovered the environment via pixi
207+
LOGGER.error('Could not find pixi executable for environment activation');
208+
return;
209+
}
210+
211+
let cancelled = false;
212+
213+
const doActivation = (): void => {
214+
try {
215+
let command = `"${pixiExe}" shell-hook --manifest-path "${manifestPath}" --json`;
216+
if (envName && envName !== 'default') {
217+
command += ` --environment "${envName}"`;
218+
}
219+
LOGGER.debug(`Running to capture Pixi variables: ${command}`);
220+
const output = execSync(command, { encoding: 'utf8', timeout: 30000 });
221+
222+
if (cancelled) {
223+
throw new Error('Pixi activation cancelled by user');
224+
}
225+
226+
// Parse JSON output
227+
const hookData: PixiShellHookJson = JSON.parse(output);
228+
229+
// Apply environment variables
230+
for (const [key, value] of Object.entries(hookData.environment_variables)) {
231+
// On Windows, convert key to uppercase for consistency
232+
const envKey = process.platform === 'win32' ? key.toUpperCase() : key;
233+
let envValue = value;
234+
235+
// On Windows, add the R binary path to PATH. Pixi does not add this
236+
// but it's needed for R to find its DLLs.
237+
if (process.platform === 'win32' && envKey === 'PATH') {
238+
envValue = path.dirname(rBinaryPath) + ';' + envValue;
239+
}
240+
env[envKey] = envValue;
241+
LOGGER.trace(`Set ${envKey}=${envValue}`);
242+
}
243+
} catch (e) {
244+
LOGGER.error('Failed to capture pixi environment variables:', e.message);
245+
if (e.stdout) {
246+
LOGGER.error('stdout:', e.stdout);
247+
}
248+
if (e.stderr) {
249+
LOGGER.error('stderr:', e.stderr);
250+
}
251+
// Don't fall back to speculative values - if pixi shell-hook fails,
252+
// the R session will start without environment activation.
253+
// R_HOME is already set, so basic functionality should still work.
254+
}
255+
};
256+
257+
const activationPromise = new Promise<void>((resolve) => {
258+
doActivation();
259+
resolve();
260+
});
261+
262+
// Show progress toast if activation takes longer than 2 seconds
263+
const progressDelay = 2000;
264+
let showProgress = true;
265+
266+
const timeoutPromise = new Promise<void>((resolve) => {
267+
setTimeout(() => {
268+
if (showProgress) {
269+
vscode.window.withProgress(
270+
{
271+
location: vscode.ProgressLocation.Notification,
272+
title: vscode.l10n.t("Activating Pixi environment '{0}'...", envName || 'default'),
273+
cancellable: true
274+
},
275+
async (_progress, token) => {
276+
token.onCancellationRequested(() => {
277+
cancelled = true;
278+
LOGGER.info('User cancelled pixi activation');
279+
});
280+
await activationPromise;
281+
}
282+
);
283+
}
284+
resolve();
285+
}, progressDelay);
286+
});
287+
288+
await Promise.race([activationPromise, timeoutPromise]);
289+
showProgress = false;
290+
await activationPromise;
291+
}
292+
168293
/**
169294
* Create a new Jupyter kernel spec.
170295
*
@@ -180,7 +305,11 @@ export async function createJupyterKernelSpec(
180305
rHomePath: string,
181306
runtimeName: string,
182307
sessionMode: positron.LanguageRuntimeSessionMode,
183-
options?: { rBinaryPath?: string; rArchitecture?: string; condaEnvironmentPath?: string }): Promise<JupyterKernelSpec> {
308+
options?: {
309+
rBinaryPath?: string;
310+
rArchitecture?: string;
311+
packagerMetadata?: PackagerMetadata;
312+
}): Promise<JupyterKernelSpec> {
184313

185314
// Path to the kernel executable
186315
const kernelPath = getArkKernelPath({
@@ -223,22 +352,32 @@ export async function createJupyterKernelSpec(
223352
env['DYLD_LIBRARY_PATH'] = rHomePath + '/lib';
224353
}
225354

226-
// If this R is from a conda environment, activate the conda environment
355+
// If this R is from a packager environment (conda or pixi), activate it
227356
// to ensure that compilation tools and other dependencies are available
228357
let startup_command: string | undefined = undefined;
229-
if (options?.condaEnvironmentPath) {
230-
const envPath = options.condaEnvironmentPath;
358+
const packagerMetadata = options?.packagerMetadata;
359+
360+
if (packagerMetadata && isCondaMetadata(packagerMetadata)) {
361+
const envPath = packagerMetadata.environmentPath;
231362
const envName = path.basename(envPath);
232363
if (process.platform === 'win32') {
233364
// On Windows, capture environment variables directly instead of using a startup command;
234365
// the startup command approach is unreliable on Windows
235-
await captureCondaEnvVarsWindows(env, envPath, envName);
366+
await captureCondaEnvVarsWindows(env, options?.rBinaryPath, envPath, envName);
236367
} else {
237368
// On Unix-like systems, use conda activate as startup command
238369
startup_command = 'conda activate ' + envPath;
239370
}
240371
}
241372

373+
// If this R is from a pixi environment, activate the pixi environment
374+
// to ensure that compilation tools and other dependencies are available
375+
if (packagerMetadata && isPixiMetadata(packagerMetadata)) {
376+
// For Pixi, we capture environment variables directly on all platforms
377+
// since pixi shell-hook --json provides a consistent interface
378+
await capturePixiEnvVars(env, options?.rBinaryPath, packagerMetadata.manifestPath, packagerMetadata.environmentName);
379+
}
380+
242381
// R script to run on session startup
243382
const startupFile = path.join(EXTENSION_ROOT_DIR, 'resources', 'scripts', 'startup.R');
244383

@@ -258,13 +397,13 @@ export async function createJupyterKernelSpec(
258397
}
259398

260399
// On Windows, we need to tell ark to use a different DLL search path when
261-
// dealing with Conda environments. Conda R installations have DLL
400+
// dealing with Conda/Pixi environments. These R installations have DLL
262401
// dependencies in non-standard locations. These locations are part of the
263-
// PATH set during Conda activation, but by default Ark has a more limited set
402+
// PATH set during environment activation, but by default Ark has a more limited set
264403
// of directories it searches for DLLs. The `--standard-dll-search-order`
265-
// option tells Ark to use Windows' standard DLL search path, which includes
404+
// option tells Ark to use Windows' standard DLL search path, which includes
266405
// the PATH entries.
267-
if (process.platform === 'win32' && options?.condaEnvironmentPath) {
406+
if (process.platform === 'win32' && packagerMetadata) {
268407
argv.push('--standard-dll-search-order');
269408
}
270409

extensions/positron-r/src/provider-conda.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export async function discoverCondaBinaries(): Promise<RBinary[]> {
108108
rBinaries.push({
109109
path: rPath,
110110
reasons: [ReasonDiscovered.CONDA],
111-
condaEnvironmentPath: envPath
111+
packagerMetadata: { environmentPath: envPath }
112112
});
113113
break;
114114
}

0 commit comments

Comments
 (0)