Skip to content

Commit c4c48fd

Browse files
Add locator for pixi environments (#22968)
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 <[email protected]>
1 parent 043962c commit c4c48fd

File tree

40 files changed

+956
-5
lines changed

40 files changed

+956
-5
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,12 @@
582582
"scope": "machine-overridable",
583583
"type": "string"
584584
},
585+
"python.pixiToolPath": {
586+
"default": "pixi",
587+
"description": "%python.pixiToolPath.description%",
588+
"scope": "machine-overridable",
589+
"type": "string"
590+
},
585591
"python.tensorBoard.logDirectory": {
586592
"default": "",
587593
"description": "%python.tensorBoard.logDirectory.description%",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"python.locator.description": "[Experimental] Select implementation of environment locators. This is an experimental setting while we test native environment location.",
6262
"python.pipenvPath.description": "Path to the pipenv executable to use for activation.",
6363
"python.poetryPath.description": "Path to the poetry executable.",
64+
"python.pixiToolPath.description": "Path to the pixi executable.",
6465
"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.",
6566
"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.",
6667
"python.tensorBoard.logDirectory.description": "Set this setting to your preferred TensorBoard log directory to skip log directory prompt when starting TensorBoard.",

resources/report_issue_user_settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"condaPath": "placeholder",
1212
"pipenvPath": "placeholder",
1313
"poetryPath": "placeholder",
14+
"pixiToolPath": "placeholder",
1415
"devOptions": false,
1516
"globalModuleInstallation": false,
1617
"languageServer": true,

src/client/common/configSettings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export class PythonSettings implements IPythonSettings {
103103

104104
public poetryPath = '';
105105

106+
public pixiToolPath = '';
107+
106108
public devOptions: string[] = [];
107109

108110
public autoComplete!: IAutoCompleteSettings;
@@ -260,6 +262,9 @@ export class PythonSettings implements IPythonSettings {
260262
this.pipenvPath = pipenvPath && pipenvPath.length > 0 ? getAbsolutePath(pipenvPath, workspaceRoot) : pipenvPath;
261263
const poetryPath = systemVariables.resolveAny(pythonSettings.get<string>('poetryPath'))!;
262264
this.poetryPath = poetryPath && poetryPath.length > 0 ? getAbsolutePath(poetryPath, workspaceRoot) : poetryPath;
265+
const pixiToolPath = systemVariables.resolveAny(pythonSettings.get<string>('pixiToolPath'))!;
266+
this.pixiToolPath =
267+
pixiToolPath && pixiToolPath.length > 0 ? getAbsolutePath(pixiToolPath, workspaceRoot) : pixiToolPath;
263268

264269
this.interpreter = pythonSettings.get<IInterpreterSettings>('interpreter') ?? {
265270
infoVisibility: 'onPythonRelated',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/* eslint-disable class-methods-use-this */
2+
// Copyright (c) Microsoft Corporation. All rights reserved.
3+
// Licensed under the MIT License.
4+
5+
import { inject, injectable } from 'inversify';
6+
import { IInterpreterService } from '../../interpreter/contracts';
7+
import { IServiceContainer } from '../../ioc/types';
8+
import { getEnvPath } from '../../pythonEnvironments/base/info/env';
9+
import { EnvironmentType, ModuleInstallerType } from '../../pythonEnvironments/info';
10+
import { ExecutionInfo, IConfigurationService } from '../types';
11+
import { isResource } from '../utils/misc';
12+
import { ModuleInstaller } from './moduleInstaller';
13+
import { InterpreterUri } from './types';
14+
import { getPixiEnvironmentFromInterpreter } from '../../pythonEnvironments/common/environmentManagers/pixi';
15+
16+
/**
17+
* A Python module installer for a pixi project.
18+
*/
19+
@injectable()
20+
export class PixiInstaller extends ModuleInstaller {
21+
constructor(
22+
@inject(IServiceContainer) serviceContainer: IServiceContainer,
23+
@inject(IConfigurationService) private readonly configurationService: IConfigurationService,
24+
) {
25+
super(serviceContainer);
26+
}
27+
28+
public get name(): string {
29+
return 'Pixi';
30+
}
31+
32+
public get displayName(): string {
33+
return 'pixi';
34+
}
35+
36+
public get type(): ModuleInstallerType {
37+
return ModuleInstallerType.Pixi;
38+
}
39+
40+
public get priority(): number {
41+
return 20;
42+
}
43+
44+
public async isSupported(resource?: InterpreterUri): Promise<boolean> {
45+
if (isResource(resource)) {
46+
const interpreter = await this.serviceContainer
47+
.get<IInterpreterService>(IInterpreterService)
48+
.getActiveInterpreter(resource);
49+
if (!interpreter || interpreter.envType !== EnvironmentType.Pixi) {
50+
return false;
51+
}
52+
53+
const pixiEnv = await getPixiEnvironmentFromInterpreter(interpreter.path);
54+
return pixiEnv !== undefined;
55+
}
56+
return resource.envType === EnvironmentType.Pixi;
57+
}
58+
59+
/**
60+
* Return the commandline args needed to install the module.
61+
*/
62+
protected async getExecutionInfo(moduleName: string, resource?: InterpreterUri): Promise<ExecutionInfo> {
63+
const pythonPath = isResource(resource)
64+
? this.configurationService.getSettings(resource).pythonPath
65+
: getEnvPath(resource.path, resource.envPath).path ?? '';
66+
67+
const pixiEnv = await getPixiEnvironmentFromInterpreter(pythonPath);
68+
const execPath = pixiEnv?.pixi.command;
69+
70+
let args = ['add', moduleName];
71+
const manifestPath = pixiEnv?.manifestPath;
72+
if (manifestPath !== undefined) {
73+
args = args.concat(['--manifest-path', manifestPath]);
74+
}
75+
76+
return {
77+
args,
78+
execPath,
79+
};
80+
}
81+
}

src/client/common/installer/productInstaller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export { Product } from '../types';
4343
// Installer implementations can check this to determine a suitable installation channel for a product
4444
// This is temporary and can be removed when https://github.com/microsoft/vscode-jupyter/issues/5034 is unblocked
4545
const UnsupportedChannelsForProduct = new Map<Product, Set<EnvironmentType>>([
46-
[Product.torchProfilerInstallName, new Set([EnvironmentType.Conda])],
46+
[Product.torchProfilerInstallName, new Set([EnvironmentType.Conda, EnvironmentType.Pixi])],
4747
]);
4848

4949
abstract class BaseInstaller implements IBaseInstaller {

src/client/common/installer/serviceRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import { InstallationChannelManager } from './channelManager';
88
import { CondaInstaller } from './condaInstaller';
99
import { PipEnvInstaller } from './pipEnvInstaller';
1010
import { PipInstaller } from './pipInstaller';
11+
import { PixiInstaller } from './pixiInstaller';
1112
import { PoetryInstaller } from './poetryInstaller';
1213
import { DataScienceProductPathService, TestFrameworkProductPathService } from './productPath';
1314
import { ProductService } from './productService';
1415
import { IInstallationChannelManager, IModuleInstaller, IProductPathService, IProductService } from './types';
1516

1617
export function registerTypes(serviceManager: IServiceManager) {
18+
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PixiInstaller);
1719
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, CondaInstaller);
1820
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PipInstaller);
1921
serviceManager.addSingleton<IModuleInstaller>(IModuleInstaller, PipEnvInstaller);

src/client/common/process/pythonEnvironment.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isTestExecution } from '../constants';
1212
import { IFileSystem } from '../platform/types';
1313
import * as internalPython from './internal/python';
1414
import { ExecutionResult, IProcessService, IPythonEnvironment, ShellOptions, SpawnOptions } from './types';
15+
import { PixiEnvironmentInfo } from '../../pythonEnvironments/common/environmentManagers/pixi';
1516

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

@@ -173,6 +174,23 @@ export async function createCondaEnv(
173174
return new PythonEnvironment(interpreterPath, deps);
174175
}
175176

177+
export async function createPixiEnv(
178+
pixiEnv: PixiEnvironmentInfo,
179+
// These are used to generate the deps.
180+
procs: IProcessService,
181+
fs: IFileSystem,
182+
): Promise<PythonEnvironment | undefined> {
183+
const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.manifestPath, pixiEnv.envName);
184+
const deps = createDeps(
185+
async (filename) => fs.pathExists(filename),
186+
pythonArgv,
187+
pythonArgv,
188+
(file, args, opts) => procs.exec(file, args, opts),
189+
(command, opts) => procs.shellExec(command, opts),
190+
);
191+
return new PythonEnvironment(pixiEnv.interpreterPath, deps);
192+
}
193+
176194
export function createMicrosoftStoreEnv(
177195
pythonPath: string,
178196
// These are used to generate the deps.

src/client/common/process/pythonExecutionFactory.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { EventName } from '../../telemetry/constants';
1010
import { IFileSystem } from '../platform/types';
1111
import { IConfigurationService, IDisposableRegistry, IInterpreterPathService } from '../types';
1212
import { ProcessService } from './proc';
13-
import { createCondaEnv, createPythonEnv, createMicrosoftStoreEnv } from './pythonEnvironment';
13+
import { createCondaEnv, createPythonEnv, createMicrosoftStoreEnv, createPixiEnv } from './pythonEnvironment';
1414
import { createPythonProcessService } from './pythonProcess';
1515
import {
1616
ExecutionFactoryCreateWithEnvironmentOptions,
@@ -25,6 +25,7 @@ import {
2525
import { IInterpreterAutoSelectionService } from '../../interpreter/autoSelection/types';
2626
import { sleep } from '../utils/async';
2727
import { traceError } from '../../logging';
28+
import { getPixiEnvironmentFromInterpreter } from '../../pythonEnvironments/common/environmentManagers/pixi';
2829

2930
@injectable()
3031
export class PythonExecutionFactory implements IPythonExecutionFactory {
@@ -79,6 +80,11 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
7980
}
8081
const processService: IProcessService = await this.processServiceFactory.create(options.resource);
8182

83+
const pixiExecutionService = await this.createPixiExecutionService(pythonPath, processService);
84+
if (pixiExecutionService) {
85+
return pixiExecutionService;
86+
}
87+
8288
const condaExecutionService = await this.createCondaExecutionService(pythonPath, processService);
8389
if (condaExecutionService) {
8490
return condaExecutionService;
@@ -116,6 +122,11 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
116122
processService.on('exec', this.logger.logProcess.bind(this.logger));
117123
this.disposables.push(processService);
118124

125+
const pixiExecutionService = await this.createPixiExecutionService(pythonPath, processService);
126+
if (pixiExecutionService) {
127+
return pixiExecutionService;
128+
}
129+
119130
const condaExecutionService = await this.createCondaExecutionService(pythonPath, processService);
120131
if (condaExecutionService) {
121132
return condaExecutionService;
@@ -139,6 +150,23 @@ export class PythonExecutionFactory implements IPythonExecutionFactory {
139150
}
140151
return createPythonService(processService, env);
141152
}
153+
154+
public async createPixiExecutionService(
155+
pythonPath: string,
156+
processService: IProcessService,
157+
): Promise<IPythonExecutionService | undefined> {
158+
const pixiEnvironment = await getPixiEnvironmentFromInterpreter(pythonPath);
159+
if (!pixiEnvironment) {
160+
return undefined;
161+
}
162+
163+
const env = await createPixiEnv(pixiEnvironment, processService, this.fileSystem);
164+
if (!env) {
165+
return undefined;
166+
}
167+
168+
return createPythonService(processService, env);
169+
}
142170
}
143171

144172
function createPythonService(procService: IProcessService, env: IPythonEnvironment): IPythonExecutionService {

src/client/common/serviceRegistry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import { ContextKeyManager } from './application/contextKeyManager';
8989
import { CreatePythonFileCommandHandler } from './application/commands/createPythonFile';
9090
import { RequireJupyterPrompt } from '../jupyter/requireJupyterPrompt';
9191
import { isWindows } from './platform/platformService';
92+
import { PixiActivationCommandProvider } from './terminal/environmentActivationProviders/pixiActivationProvider';
9293

9394
export function registerTypes(serviceManager: IServiceManager): void {
9495
serviceManager.addSingletonInstance<boolean>(IsWindows, isWindows());
@@ -161,6 +162,11 @@ export function registerTypes(serviceManager: IServiceManager): void {
161162
CondaActivationCommandProvider,
162163
TerminalActivationProviders.conda,
163164
);
165+
serviceManager.addSingleton<ITerminalActivationCommandProvider>(
166+
ITerminalActivationCommandProvider,
167+
PixiActivationCommandProvider,
168+
TerminalActivationProviders.pixi,
169+
);
164170
serviceManager.addSingleton<ITerminalActivationCommandProvider>(
165171
ITerminalActivationCommandProvider,
166172
PipEnvActivationCommandProvider,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/* eslint-disable class-methods-use-this */
2+
// Copyright (c) Microsoft Corporation. All rights reserved.
3+
// Licensed under the MIT License.
4+
5+
'use strict';
6+
7+
import { inject, injectable } from 'inversify';
8+
import { Uri } from 'vscode';
9+
import { IInterpreterService } from '../../../interpreter/contracts';
10+
import { ITerminalActivationCommandProvider, TerminalShellType } from '../types';
11+
import { traceError } from '../../../logging';
12+
import {
13+
getPixiEnvironmentFromInterpreter,
14+
isNonDefaultPixiEnvironmentName,
15+
} from '../../../pythonEnvironments/common/environmentManagers/pixi';
16+
import { exec } from '../../../pythonEnvironments/common/externalDependencies';
17+
import { splitLines } from '../../stringUtils';
18+
19+
@injectable()
20+
export class PixiActivationCommandProvider implements ITerminalActivationCommandProvider {
21+
constructor(@inject(IInterpreterService) private readonly interpreterService: IInterpreterService) {}
22+
23+
// eslint-disable-next-line class-methods-use-this
24+
public isShellSupported(targetShell: TerminalShellType): boolean {
25+
return shellTypeToPixiShell(targetShell) !== undefined;
26+
}
27+
28+
public async getActivationCommands(
29+
resource: Uri | undefined,
30+
targetShell: TerminalShellType,
31+
): Promise<string[] | undefined> {
32+
const interpreter = await this.interpreterService.getActiveInterpreter(resource);
33+
if (!interpreter) {
34+
return undefined;
35+
}
36+
37+
return this.getActivationCommandsForInterpreter(interpreter.path, targetShell);
38+
}
39+
40+
public async getActivationCommandsForInterpreter(
41+
pythonPath: string,
42+
targetShell: TerminalShellType,
43+
): Promise<string[] | undefined> {
44+
const pixiEnv = await getPixiEnvironmentFromInterpreter(pythonPath);
45+
if (!pixiEnv) {
46+
return undefined;
47+
}
48+
49+
const command = ['shell-hook', '--manifest-path', pixiEnv.manifestPath];
50+
if (isNonDefaultPixiEnvironmentName(pixiEnv.envName)) {
51+
command.push('--environment');
52+
command.push(pixiEnv.envName);
53+
}
54+
55+
const pixiTargetShell = shellTypeToPixiShell(targetShell);
56+
if (pixiTargetShell) {
57+
command.push('--shell');
58+
command.push(pixiTargetShell);
59+
}
60+
61+
const shellHookOutput = await exec(pixiEnv.pixi.command, command, {
62+
throwOnStdErr: false,
63+
}).catch(traceError);
64+
if (!shellHookOutput) {
65+
return undefined;
66+
}
67+
68+
return splitLines(shellHookOutput.stdout, {
69+
removeEmptyEntries: true,
70+
trim: true,
71+
});
72+
}
73+
}
74+
75+
/**
76+
* Returns the name of a terminal shell type within Pixi.
77+
*/
78+
function shellTypeToPixiShell(targetShell: TerminalShellType): string | undefined {
79+
switch (targetShell) {
80+
case TerminalShellType.powershell:
81+
case TerminalShellType.powershellCore:
82+
return 'powershell';
83+
case TerminalShellType.commandPrompt:
84+
return 'cmd';
85+
86+
case TerminalShellType.zsh:
87+
return 'zsh';
88+
89+
case TerminalShellType.fish:
90+
return 'fish';
91+
92+
case TerminalShellType.nushell:
93+
return 'nushell';
94+
95+
case TerminalShellType.xonsh:
96+
return 'xonsh';
97+
98+
case TerminalShellType.cshell:
99+
// Explicitly unsupported
100+
return undefined;
101+
102+
case TerminalShellType.gitbash:
103+
case TerminalShellType.bash:
104+
case TerminalShellType.wsl:
105+
case TerminalShellType.tcshell:
106+
case TerminalShellType.other:
107+
default:
108+
return 'bash';
109+
}
110+
}

0 commit comments

Comments
 (0)