Skip to content

Commit

Permalink
Add locator for pixi environments (#22968)
Browse files Browse the repository at this point in the history
Closes #22978

This adds a locator implementation that properly detects
[Pixi](https://pixi.sh/) environments. Pixi environments are essentially
conda environments but placed in a specific directory inside the
project/workspace. This PR properly detects these and does not do much
else. This would unblock a lot of pixi users.

I would prefer to use a custom pixi plugin but since the [contribution
endpoints are not available
yet](#22797) I think
this is the next best thing.

Before I put more effort into tests I just want to verify that this
approach is valid. Let me know what you think! :)

---------

Co-authored-by: Tim de Jager <tim@prefix.dev>
  • Loading branch information
baszalmstra and tdejager committed Jun 20, 2024
1 parent 043962c commit c4c48fd
Show file tree
Hide file tree
Showing 40 changed files with 956 additions and 5 deletions.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,12 @@
"scope": "machine-overridable",
"type": "string"
},
"python.pixiToolPath": {
"default": "pixi",
"description": "%python.pixiToolPath.description%",
"scope": "machine-overridable",
"type": "string"
},
"python.tensorBoard.logDirectory": {
"default": "",
"description": "%python.tensorBoard.logDirectory.description%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"python.locator.description": "[Experimental] Select implementation of environment locators. This is an experimental setting while we test native environment location.",
"python.pipenvPath.description": "Path to the pipenv executable to use for activation.",
"python.poetryPath.description": "Path to the poetry executable.",
"python.pixiToolPath.description": "Path to the pixi executable.",
"python.EnableREPLSmartSend.description": "Toggle Smart Send for the Python REPL. Smart Send enables sending the smallest runnable block of code to the REPL on Shift+Enter and moves the cursor accordingly.",
"python.REPL.sendToNativeREPL.description": "Toggle to send code to Python REPL instead of the terminal on execution. Turning this on will change the behavior for both Smart Send and Run Selection/Line in the Context Menu.",
"python.tensorBoard.logDirectory.description": "Set this setting to your preferred TensorBoard log directory to skip log directory prompt when starting TensorBoard.",
Expand Down
1 change: 1 addition & 0 deletions resources/report_issue_user_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"condaPath": "placeholder",
"pipenvPath": "placeholder",
"poetryPath": "placeholder",
"pixiToolPath": "placeholder",
"devOptions": false,
"globalModuleInstallation": false,
"languageServer": true,
Expand Down
5 changes: 5 additions & 0 deletions src/client/common/configSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export class PythonSettings implements IPythonSettings {

public poetryPath = '';

public pixiToolPath = '';

public devOptions: string[] = [];

public autoComplete!: IAutoCompleteSettings;
Expand Down Expand Up @@ -260,6 +262,9 @@ export class PythonSettings implements IPythonSettings {
this.pipenvPath = pipenvPath && pipenvPath.length > 0 ? getAbsolutePath(pipenvPath, workspaceRoot) : pipenvPath;
const poetryPath = systemVariables.resolveAny(pythonSettings.get<string>('poetryPath'))!;
this.poetryPath = poetryPath && poetryPath.length > 0 ? getAbsolutePath(poetryPath, workspaceRoot) : poetryPath;
const pixiToolPath = systemVariables.resolveAny(pythonSettings.get<string>('pixiToolPath'))!;
this.pixiToolPath =
pixiToolPath && pixiToolPath.length > 0 ? getAbsolutePath(pixiToolPath, workspaceRoot) : pixiToolPath;

this.interpreter = pythonSettings.get<IInterpreterSettings>('interpreter') ?? {
infoVisibility: 'onPythonRelated',
Expand Down
81 changes: 81 additions & 0 deletions src/client/common/installer/pixiInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable class-methods-use-this */
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { inject, injectable } from 'inversify';
import { IInterpreterService } from '../../interpreter/contracts';
import { IServiceContainer } from '../../ioc/types';
import { getEnvPath } from '../../pythonEnvironments/base/info/env';
import { EnvironmentType, ModuleInstallerType } from '../../pythonEnvironments/info';
import { ExecutionInfo, IConfigurationService } from '../types';
import { isResource } from '../utils/misc';
import { ModuleInstaller } from './moduleInstaller';
import { InterpreterUri } from './types';
import { getPixiEnvironmentFromInterpreter } from '../../pythonEnvironments/common/environmentManagers/pixi';

/**
* A Python module installer for a pixi project.
*/
@injectable()
export class PixiInstaller extends ModuleInstaller {
constructor(
@inject(IServiceContainer) serviceContainer: IServiceContainer,
@inject(IConfigurationService) private readonly configurationService: IConfigurationService,
) {
super(serviceContainer);
}

public get name(): string {
return 'Pixi';
}

public get displayName(): string {
return 'pixi';
}

public get type(): ModuleInstallerType {
return ModuleInstallerType.Pixi;
}

public get priority(): number {
return 20;
}

public async isSupported(resource?: InterpreterUri): Promise<boolean> {
if (isResource(resource)) {
const interpreter = await this.serviceContainer
.get<IInterpreterService>(IInterpreterService)
.getActiveInterpreter(resource);
if (!interpreter || interpreter.envType !== EnvironmentType.Pixi) {
return false;
}

const pixiEnv = await getPixiEnvironmentFromInterpreter(interpreter.path);
return pixiEnv !== undefined;
}
return resource.envType === EnvironmentType.Pixi;
}

/**
* Return the commandline args needed to install the module.
*/
protected async getExecutionInfo(moduleName: string, resource?: InterpreterUri): Promise<ExecutionInfo> {
const pythonPath = isResource(resource)
? this.configurationService.getSettings(resource).pythonPath
: getEnvPath(resource.path, resource.envPath).path ?? '';

const pixiEnv = await getPixiEnvironmentFromInterpreter(pythonPath);
const execPath = pixiEnv?.pixi.command;

let args = ['add', moduleName];
const manifestPath = pixiEnv?.manifestPath;
if (manifestPath !== undefined) {
args = args.concat(['--manifest-path', manifestPath]);
}

return {
args,
execPath,
};
}
}
2 changes: 1 addition & 1 deletion src/client/common/installer/productInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export { Product } from '../types';
// Installer implementations can check this to determine a suitable installation channel for a product
// This is temporary and can be removed when https://github.com/microsoft/vscode-jupyter/issues/5034 is unblocked
const UnsupportedChannelsForProduct = new Map<Product, Set<EnvironmentType>>([
[Product.torchProfilerInstallName, new Set([EnvironmentType.Conda])],
[Product.torchProfilerInstallName, new Set([EnvironmentType.Conda, EnvironmentType.Pixi])],
]);

abstract class BaseInstaller implements IBaseInstaller {
Expand Down
2 changes: 2 additions & 0 deletions src/client/common/installer/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { InstallationChannelManager } from './channelManager';
import { CondaInstaller } from './condaInstaller';
import { PipEnvInstaller } from './pipEnvInstaller';
import { PipInstaller } from './pipInstaller';
import { PixiInstaller } from './pixiInstaller';
import { PoetryInstaller } from './poetryInstaller';
import { DataScienceProductPathService, TestFrameworkProductPathService } from './productPath';
import { ProductService } from './productService';
import { IInstallationChannelManager, IModuleInstaller, IProductPathService, IProductService } from './types';

export function registerTypes(serviceManager: IServiceManager) {
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PixiInstaller);
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, CondaInstaller);
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PipInstaller);
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PipEnvInstaller);
Expand Down
18 changes: 18 additions & 0 deletions src/client/common/process/pythonEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isTestExecution } from '../constants';
import { IFileSystem } from '../platform/types';
import * as internalPython from './internal/python';
import { ExecutionResult, IProcessService, IPythonEnvironment, ShellOptions, SpawnOptions } from './types';
import { PixiEnvironmentInfo } from '../../pythonEnvironments/common/environmentManagers/pixi';

const cachedExecutablePath: Map<string, Promise<string | undefined>> = new Map<string, Promise<string | undefined>>();

Expand Down Expand Up @@ -173,6 +174,23 @@ export async function createCondaEnv(
return new PythonEnvironment(interpreterPath, deps);
}

export async function createPixiEnv(
pixiEnv: PixiEnvironmentInfo,
// These are used to generate the deps.
procs: IProcessService,
fs: IFileSystem,
): Promise<PythonEnvironment | undefined> {
const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.manifestPath, pixiEnv.envName);
const deps = createDeps(
async (filename) => fs.pathExists(filename),
pythonArgv,
pythonArgv,
(file, args, opts) => procs.exec(file, args, opts),
(command, opts) => procs.shellExec(command, opts),
);
return new PythonEnvironment(pixiEnv.interpreterPath, deps);
}

export function createMicrosoftStoreEnv(
pythonPath: string,
// These are used to generate the deps.
Expand Down
30 changes: 29 additions & 1 deletion src/client/common/process/pythonExecutionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EventName } from '../../telemetry/constants';
import { IFileSystem } from '../platform/types';
import { IConfigurationService, IDisposableRegistry, IInterpreterPathService } from '../types';
import { ProcessService } from './proc';
import { createCondaEnv, createPythonEnv, createMicrosoftStoreEnv } from './pythonEnvironment';
import { createCondaEnv, createPythonEnv, createMicrosoftStoreEnv, createPixiEnv } from './pythonEnvironment';
import { createPythonProcessService } from './pythonProcess';
import {
ExecutionFactoryCreateWithEnvironmentOptions,
Expand All @@ -25,6 +25,7 @@ import {
import { IInterpreterAutoSelectionService } from '../../interpreter/autoSelection/types';
import { sleep } from '../utils/async';
import { traceError } from '../../logging';
import { getPixiEnvironmentFromInterpreter } from '../../pythonEnvironments/common/environmentManagers/pixi';

@injectable()
export class PythonExecutionFactory implements IPythonExecutionFactory {
Expand Down Expand Up @@ -79,6 +80,11 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
}
const processService: IProcessService = await this.processServiceFactory.create(options.resource);

const pixiExecutionService = await this.createPixiExecutionService(pythonPath, processService);
if (pixiExecutionService) {
return pixiExecutionService;
}

const condaExecutionService = await this.createCondaExecutionService(pythonPath, processService);
if (condaExecutionService) {
return condaExecutionService;
Expand Down Expand Up @@ -116,6 +122,11 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
processService.on('exec', this.logger.logProcess.bind(this.logger));
this.disposables.push(processService);

const pixiExecutionService = await this.createPixiExecutionService(pythonPath, processService);
if (pixiExecutionService) {
return pixiExecutionService;
}

const condaExecutionService = await this.createCondaExecutionService(pythonPath, processService);
if (condaExecutionService) {
return condaExecutionService;
Expand All @@ -139,6 +150,23 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
}
return createPythonService(processService, env);
}

public async createPixiExecutionService(
pythonPath: string,
processService: IProcessService,
): Promise<IPythonExecutionService | undefined> {
const pixiEnvironment = await getPixiEnvironmentFromInterpreter(pythonPath);
if (!pixiEnvironment) {
return undefined;
}

const env = await createPixiEnv(pixiEnvironment, processService, this.fileSystem);
if (!env) {
return undefined;
}

return createPythonService(processService, env);
}
}

function createPythonService(procService: IProcessService, env: IPythonEnvironment): IPythonExecutionService {
Expand Down
6 changes: 6 additions & 0 deletions src/client/common/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { ContextKeyManager } from './application/contextKeyManager';
import { CreatePythonFileCommandHandler } from './application/commands/createPythonFile';
import { RequireJupyterPrompt } from '../jupyter/requireJupyterPrompt';
import { isWindows } from './platform/platformService';
import { PixiActivationCommandProvider } from './terminal/environmentActivationProviders/pixiActivationProvider';

export function registerTypes(serviceManager: IServiceManager): void {
serviceManager.addSingletonInstance<boolean>(IsWindows, isWindows());
Expand Down Expand Up @@ -161,6 +162,11 @@ export function registerTypes(serviceManager: IServiceManager): void {
CondaActivationCommandProvider,
TerminalActivationProviders.conda,
);
serviceManager.addSingleton<ITerminalActivationCommandProvider>(
ITerminalActivationCommandProvider,
PixiActivationCommandProvider,
TerminalActivationProviders.pixi,
);
serviceManager.addSingleton<ITerminalActivationCommandProvider>(
ITerminalActivationCommandProvider,
PipEnvActivationCommandProvider,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/* eslint-disable class-methods-use-this */
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

'use strict';

import { inject, injectable } from 'inversify';
import { Uri } from 'vscode';
import { IInterpreterService } from '../../../interpreter/contracts';
import { ITerminalActivationCommandProvider, TerminalShellType } from '../types';
import { traceError } from '../../../logging';
import {
getPixiEnvironmentFromInterpreter,
isNonDefaultPixiEnvironmentName,
} from '../../../pythonEnvironments/common/environmentManagers/pixi';
import { exec } from '../../../pythonEnvironments/common/externalDependencies';
import { splitLines } from '../../stringUtils';

@injectable()
export class PixiActivationCommandProvider implements ITerminalActivationCommandProvider {
constructor(@inject(IInterpreterService) private readonly interpreterService: IInterpreterService) {}

// eslint-disable-next-line class-methods-use-this
public isShellSupported(targetShell: TerminalShellType): boolean {
return shellTypeToPixiShell(targetShell) !== undefined;
}

public async getActivationCommands(
resource: Uri | undefined,
targetShell: TerminalShellType,
): Promise<string[] | undefined> {
const interpreter = await this.interpreterService.getActiveInterpreter(resource);
if (!interpreter) {
return undefined;
}

return this.getActivationCommandsForInterpreter(interpreter.path, targetShell);
}

public async getActivationCommandsForInterpreter(
pythonPath: string,
targetShell: TerminalShellType,
): Promise<string[] | undefined> {
const pixiEnv = await getPixiEnvironmentFromInterpreter(pythonPath);
if (!pixiEnv) {
return undefined;
}

const command = ['shell-hook', '--manifest-path', pixiEnv.manifestPath];
if (isNonDefaultPixiEnvironmentName(pixiEnv.envName)) {
command.push('--environment');
command.push(pixiEnv.envName);
}

const pixiTargetShell = shellTypeToPixiShell(targetShell);
if (pixiTargetShell) {
command.push('--shell');
command.push(pixiTargetShell);
}

const shellHookOutput = await exec(pixiEnv.pixi.command, command, {
throwOnStdErr: false,
}).catch(traceError);
if (!shellHookOutput) {
return undefined;
}

return splitLines(shellHookOutput.stdout, {
removeEmptyEntries: true,
trim: true,
});
}
}

/**
* Returns the name of a terminal shell type within Pixi.
*/
function shellTypeToPixiShell(targetShell: TerminalShellType): string | undefined {
switch (targetShell) {
case TerminalShellType.powershell:
case TerminalShellType.powershellCore:
return 'powershell';
case TerminalShellType.commandPrompt:
return 'cmd';

case TerminalShellType.zsh:
return 'zsh';

case TerminalShellType.fish:
return 'fish';

case TerminalShellType.nushell:
return 'nushell';

case TerminalShellType.xonsh:
return 'xonsh';

case TerminalShellType.cshell:
// Explicitly unsupported
return undefined;

case TerminalShellType.gitbash:
case TerminalShellType.bash:
case TerminalShellType.wsl:
case TerminalShellType.tcshell:
case TerminalShellType.other:
default:
return 'bash';
}
}
Loading

0 comments on commit c4c48fd

Please sign in to comment.