Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add locator for pixi environments #22968

Merged
merged 13 commits into from
Jun 20, 2024
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,12 @@
"scope": "machine-overridable",
"type": "string"
},
"python.pixiPath": {
"default": "pixi",
"description": "%python.pixiPath.description%",
"scope": "machine-overridable",
"type": "string"
},
karrtikr marked this conversation as resolved.
Show resolved Hide resolved
"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 @@ -59,6 +59,7 @@
"python.missingPackage.severity.description": "Set severity of missing packages in requirements.txt or pyproject.toml",
"python.pipenvPath.description": "Path to the pipenv executable to use for activation.",
"python.poetryPath.description": "Path to the poetry executable.",
"python.pixiPath.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.tensorBoard.logDirectory.description": "Set this setting to your preferred TensorBoard log directory to skip log directory prompt when starting TensorBoard.",
"python.tensorBoard.logDirectory.markdownDeprecationMessage": "Tensorboard support has been moved to the extension [Tensorboard extension](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.tensorboard). Instead use the setting `tensorBoard.logDirectory`.",
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",
"pixiPath": "placeholder",
"devOptions": false,
"globalModuleInstallation": false,
"languageServer": true,
Expand Down
4 changes: 4 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 pixiPath = '';

public devOptions: string[] = [];

public autoComplete!: IAutoCompleteSettings;
Expand Down Expand Up @@ -260,6 +262,8 @@ 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 pixiPath = systemVariables.resolveAny(pythonSettings.get<string>('pixiPath'))!;
this.pixiPath = pixiPath && pixiPath.length > 0 ? getAbsolutePath(pixiPath, workspaceRoot) : pixiPath;

this.interpreter = pythonSettings.get<IInterpreterSettings>('interpreter') ?? {
infoVisibility: 'onPythonRelated',
Expand Down
1 change: 1 addition & 0 deletions src/client/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export interface IPythonSettings {
readonly condaPath: string;
readonly pipenvPath: string;
readonly poetryPath: string;
readonly pixiPath: string;
readonly devOptions: string[];
readonly testing: ITestingSettings;
readonly autoComplete: IAutoCompleteSettings;
Expand Down
3 changes: 3 additions & 0 deletions src/client/pythonEnvironments/base/info/envKind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
[PythonEnvKind.MicrosoftStore, 'Microsoft Store'],
[PythonEnvKind.Pyenv, 'pyenv'],
[PythonEnvKind.Poetry, 'Poetry'],
[PythonEnvKind.Pixi, 'Pixi'],
[PythonEnvKind.Custom, 'custom'],
// For now we treat OtherGlobal like Unknown.
[PythonEnvKind.Venv, 'venv'],
Expand Down Expand Up @@ -45,6 +46,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
* 3. PipEnv
* 4. Pyenv
* 5. Poetry
* 6. Pixi
*
* Next level we have the following virtual environment tools. The are here because they
* are consumed by the tools above, and can also be used independently.
Expand All @@ -57,6 +59,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string {
export function getPrioritizedEnvKinds(): PythonEnvKind[] {
return [
PythonEnvKind.Pyenv,
PythonEnvKind.Pixi, // Placed here since Pixi environments are essentially Conda envs
PythonEnvKind.Conda,
PythonEnvKind.MicrosoftStore,
PythonEnvKind.Pipenv,
Expand Down
2 changes: 2 additions & 0 deletions src/client/pythonEnvironments/base/info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum PythonEnvKind {
MicrosoftStore = 'global-microsoft-store',
Pyenv = 'global-pyenv',
Poetry = 'poetry',
Pixi = 'pixi',
ActiveState = 'activestate',
Custom = 'global-custom',
OtherGlobal = 'global-other',
Expand Down Expand Up @@ -44,6 +45,7 @@ export interface EnvPathType {

export const virtualEnvKinds = [
PythonEnvKind.Poetry,
PythonEnvKind.Pixi,
PythonEnvKind.Pipenv,
PythonEnvKind.Venv,
PythonEnvKind.VirtualEnvWrapper,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { asyncFilter } from '../../../../common/utils/arrayUtils';
import { chain, iterable } from '../../../../common/utils/async';
import { traceError, traceVerbose } from '../../../../logging';
import { getCondaInterpreterPath } from '../../../common/environmentManagers/conda';
import { Pixi } from '../../../common/environmentManagers/pixi';
import { pathExists } from '../../../common/externalDependencies';
import { PythonEnvKind } from '../../info';
import { IPythonEnvsIterator, BasicEnvInfo } from '../../locator';
import { LazyResourceBasedLocator } from '../common/resourceBasedLocator';

/**
* Returns all virtual environment locations to look for in a workspace.
*/
async function getVirtualEnvDirs(root: string): Promise<string[]> {
const pixi = await Pixi.getPixi(root);
const envDirs = (await pixi?.getEnvList()) ?? [];
return asyncFilter(envDirs, pathExists);
}

export class PixiLocator extends LazyResourceBasedLocator {
public readonly providerId: string = 'pixi';

public constructor(private readonly root: string) {
super();
}

protected doIterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
async function* iterator(root: string) {
const envDirs = await getVirtualEnvDirs(root);
const envGenerators = envDirs.map((envDir) => {
async function* generator() {
traceVerbose(`Searching for Pixi virtual envs in: ${envDir}`);
const filename = await getCondaInterpreterPath(envDir);
if (filename !== undefined) {
try {
yield {
executablePath: filename,
kind: PythonEnvKind.Pixi,
envPath: envDir,
};

traceVerbose(`Pixi Virtual Environment: [added] ${filename}`);
} catch (ex) {
traceError(`Failed to process environment: ${filename}`, ex);
}
}
}
return generator();
});

yield* iterable(chain(envGenerators));
traceVerbose(`Finished searching for Pixi envs`);
}

return iterator(this.root);
}
}
2 changes: 2 additions & 0 deletions src/client/pythonEnvironments/common/environmentIdentifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './environmentManagers/simplevirtualenvs';
import { isMicrosoftStoreEnvironment } from './environmentManagers/microsoftStoreEnv';
import { isActiveStateEnvironment } from './environmentManagers/activestate';
import { isPixiEnvironment } from './environmentManagers/pixi';

function getIdentifiers(): Map<PythonEnvKind, (path: string) => Promise<boolean>> {
const notImplemented = () => Promise.resolve(false);
Expand All @@ -30,6 +31,7 @@ function getIdentifiers(): Map<PythonEnvKind, (path: string) => Promise<boolean>
identifier.set(PythonEnvKind.Pipenv, isPipenvEnvironment);
identifier.set(PythonEnvKind.Pyenv, isPyenvEnvironment);
identifier.set(PythonEnvKind.Poetry, isPoetryEnvironment);
identifier.set(PythonEnvKind.Pixi, isPixiEnvironment);
identifier.set(PythonEnvKind.Venv, isVenvEnvironment);
identifier.set(PythonEnvKind.VirtualEnvWrapper, isVirtualEnvWrapperEnvironment);
identifier.set(PythonEnvKind.VirtualEnv, isVirtualEnvEnvironment);
Expand Down
181 changes: 181 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/pixi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

'use strict';

import * as path from 'path';
import { OSType, getOSType, getUserHomeDir } from '../../../common/utils/platform';
import { exec, getPythonSetting, pathExists, pathExistsSync } from '../externalDependencies';
import { cache } from '../../../common/utils/decorators';
import { isTestExecution } from '../../../common/constants';
import { traceError, traceVerbose } from '../../../logging';

// This type corresponds to the output of 'pixi info --json', and property
// names must be spelled exactly as they are in order to match the schema.
export type PixiInfo = {
platform: string;
virtual_packages: string[]; // eslint-disable-line camelcase
version: string;
cache_dir: string; // eslint-disable-line camelcase
cache_size?: number; // eslint-disable-line camelcase
auth_dir: string; // eslint-disable-line camelcase

project_info?: /* eslint-disable-line camelcase */ {
manifest_path: string; // eslint-disable-line camelcase
last_updated: string; // eslint-disable-line camelcase
pixi_folder_size?: number; // eslint-disable-line camelcase
version: string;
};

environments_info: /* eslint-disable-line camelcase */ {
name: string;
features: string[];
solve_group: string; // eslint-disable-line camelcase
environment_size: number; // eslint-disable-line camelcase
dependencies: string[];
tasks: string[];
channels: string[];
prefix: string;
}[];
};

export async function isPixiEnvironment(interpreterPath: string): Promise<boolean> {
// We want to verify the following layout
// project
// |__ pixi.toml <-- check if this exists
// |__ .pixi
// |__ envs
// |__ <environment>
// |__ bin/""
// |__ python <-- interpreterPath

const envDir = getCondaEnvironmentFromInterpreterPath(interpreterPath);
const envsDir = path.dirname(envDir);
const pixiDir = path.dirname(envsDir);
const projectDir = path.dirname(pixiDir);
const pixiTomlPath = path.join(projectDir, 'pixi.toml');
return pathExists(pixiTomlPath);
}

/**
* Returns the path to the environment directory based on the interpreter path.
*/
export function getCondaEnvironmentFromInterpreterPath(interpreterPath: string): string {
const interpreterDir = path.dirname(interpreterPath);
if (getOSType() === OSType.Windows) {
return interpreterDir;
}
return path.dirname(interpreterDir);
}

/** Wraps the "pixi" utility, and exposes its functionality.
*/
export class Pixi {
/**
* Locating pixi binary can be expensive, since it potentially involves spawning or
* trying to spawn processes; so we only do it once per session.
*/
private static pixiPromise: Map<string, Promise<Pixi | undefined>> = new Map<string, Promise<Pixi | undefined>>();

/**
* Creates a Pixi service corresponding to the corresponding "pixi" command.
*
* @param command - Command used to run pixi. This has the same meaning as the
* first argument of spawn() - i.e. it can be a full path, or just a binary name.
* @param cwd - The working directory to use as cwd when running pixi.
*/
constructor(public readonly command: string, private cwd: string) {}

/**
* Returns a Pixi instance corresponding to the binary which can be used to run commands for the cwd.
*
* Pixi commands can be slow and so can be bottleneck to overall discovery time. So trigger command
* execution as soon as possible. To do that we need to ensure the operations before the command are
* performed synchronously.
*/
public static async getPixi(cwd: string): Promise<Pixi | undefined> {
if (Pixi.pixiPromise.get(cwd) === undefined || isTestExecution()) {
Pixi.pixiPromise.set(cwd, Pixi.locate(cwd));
}
return Pixi.pixiPromise.get(cwd);
}

private static async locate(cwd: string): Promise<Pixi | undefined> {
// First thing this method awaits on should be pixi command execution, hence perform all operations
// before that synchronously.

traceVerbose(`Getting pixi for cwd ${cwd}`);
// Produce a list of candidate binaries to be probed by exec'ing them.
function* getCandidates() {
// Read the pixi location from the settings.
try {
const customPixiPath = getPythonSetting<string>('pixiPath');
if (customPixiPath && customPixiPath !== 'pixi') {
// If user has specified a custom pixi path, use it first.
yield customPixiPath;
}
} catch (ex) {
traceError(`Failed to get pixi setting`, ex);
}

// Check unqualified filename, in case it's on PATH.
yield 'pixi';

// Check the default installation location
const home = getUserHomeDir();
if (home) {
const defaultPixiPath = path.join(home, '.pixi', 'bin', 'pixi');
if (pathExistsSync(defaultPixiPath)) {
yield defaultPixiPath;
}
}
}

// Probe the candidates, and pick the first one that exists and does what we need.
for (const pixiPath of getCandidates()) {
traceVerbose(`Probing pixi binary for ${cwd}: ${pixiPath}`);
const pixi = new Pixi(pixiPath, cwd);
const virtualenvs = await pixi.getEnvList();
if (virtualenvs !== undefined) {
traceVerbose(`Found pixi via filesystem probing for ${cwd}: ${pixiPath}`);
return pixi;
}
traceVerbose(`Failed to find pixi for ${cwd}: ${pixiPath}`);
}

// Didn't find anything.
traceVerbose(`No pixi binary found for ${cwd}`);
return undefined;
}

/**
* Retrieves list of Python environments known to this pixi for this working directory.
* Returns `undefined` if we failed to spawn because the binary doesn't exist or isn't on PATH,
* or the current user doesn't have execute permissions for it, or this pixi couldn't handle
* command line arguments that we passed (indicating an old version that we do not support, or
* pixi has not been setup properly for the cwd).
*
* Corresponds to "pixi info --json" and extracting the environments. Swallows errors if any.
*/
public async getEnvList(): Promise<string[] | undefined> {
return this.getEnvListCached(this.cwd);
}

/**
* Method created to facilitate caching. The caching decorator uses function arguments as cache key,
* so pass in cwd on which we need to cache.
*/
@cache(30_000, true, 10_000)
private async getEnvListCached(_cwd: string): Promise<string[] | undefined> {
const infoOutput = await exec(this.command, ['info', '--json'], {
cwd: this.cwd,
throwOnStdErr: true,
}).catch(traceVerbose);
if (!infoOutput) {
return undefined;
}

const pixiInfo: PixiInfo = JSON.parse(infoOutput.stdout);
return pixiInfo.environments_info.map((env) => env.prefix);
}
}
2 changes: 2 additions & 0 deletions src/client/pythonEnvironments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { IDisposable } from '../common/types';
import { traceError } from '../logging';
import { ActiveStateLocator } from './base/locators/lowLevel/activeStateLocator';
import { CustomWorkspaceLocator } from './base/locators/lowLevel/customWorkspaceLocator';
import { PixiLocator } from './base/locators/lowLevel/pixiLocator';

/**
* Set up the Python environments component (during extension activation).'
Expand Down Expand Up @@ -186,6 +187,7 @@ function createWorkspaceLocator(ext: ExtensionState): WorkspaceLocators {
(root: vscode.Uri) => [
new WorkspaceVirtualEnvironmentLocator(root.fsPath),
new PoetryLocator(root.fsPath),
new PixiLocator(root.fsPath),
new CustomWorkspaceLocator(root.fsPath),
],
// Add an ILocator factory func here for each kind of workspace-rooted locator.
Expand Down
2 changes: 2 additions & 0 deletions src/test/common/configSettings/configSettings.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ suite('Python Settings', async () => {
'pipenvPath',
'envFile',
'poetryPath',
'pixiPath',
'defaultInterpreterPath',
]) {
config
Expand Down Expand Up @@ -141,6 +142,7 @@ suite('Python Settings', async () => {
'pipenvPath',
'envFile',
'poetryPath',
'pixiPath',
'defaultInterpreterPath',
].forEach(async (settingName) => {
testIfValueIsUpdated(settingName, 'stringValue');
Expand Down
1 change: 1 addition & 0 deletions src/test/pythonEnvironments/base/info/envKind.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const KIND_NAMES: [PythonEnvKind, string][] = [
[PythonEnvKind.MicrosoftStore, 'winStore'],
[PythonEnvKind.Pyenv, 'pyenv'],
[PythonEnvKind.Poetry, 'poetry'],
[PythonEnvKind.Pixi, 'pixi'],
[PythonEnvKind.Custom, 'customGlobal'],
[PythonEnvKind.OtherGlobal, 'otherGlobal'],
[PythonEnvKind.Venv, 'venv'],
Expand Down
Loading