diff --git a/package.json b/package.json index b936cf59fe45..d12beb9a0f91 100644 --- a/package.json +++ b/package.json @@ -580,6 +580,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%", diff --git a/package.nls.json b/package.nls.json index 50c02073bfda..dcf8a2ddf5f9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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.", diff --git a/resources/report_issue_user_settings.json b/resources/report_issue_user_settings.json index eea4ca007da6..ef85267c0e65 100644 --- a/resources/report_issue_user_settings.json +++ b/resources/report_issue_user_settings.json @@ -11,6 +11,7 @@ "condaPath": "placeholder", "pipenvPath": "placeholder", "poetryPath": "placeholder", + "pixiToolPath": "placeholder", "devOptions": false, "globalModuleInstallation": false, "languageServer": true, diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 88a5007467bb..dbd78c5287e5 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -103,6 +103,8 @@ export class PythonSettings implements IPythonSettings { public poetryPath = ''; + public pixiToolPath = ''; + public devOptions: string[] = []; public autoComplete!: IAutoCompleteSettings; @@ -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('poetryPath'))!; this.poetryPath = poetryPath && poetryPath.length > 0 ? getAbsolutePath(poetryPath, workspaceRoot) : poetryPath; + const pixiToolPath = systemVariables.resolveAny(pythonSettings.get('pixiToolPath'))!; + this.pixiToolPath = + pixiToolPath && pixiToolPath.length > 0 ? getAbsolutePath(pixiToolPath, workspaceRoot) : pixiToolPath; this.interpreter = pythonSettings.get('interpreter') ?? { infoVisibility: 'onPythonRelated', diff --git a/src/client/common/installer/pixiInstaller.ts b/src/client/common/installer/pixiInstaller.ts new file mode 100644 index 000000000000..8a2278830b51 --- /dev/null +++ b/src/client/common/installer/pixiInstaller.ts @@ -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 { + if (isResource(resource)) { + const interpreter = await this.serviceContainer + .get(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 { + 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, + }; + } +} diff --git a/src/client/common/installer/productInstaller.ts b/src/client/common/installer/productInstaller.ts index fba860aaa383..831eb33efbc6 100644 --- a/src/client/common/installer/productInstaller.ts +++ b/src/client/common/installer/productInstaller.ts @@ -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.torchProfilerInstallName, new Set([EnvironmentType.Conda])], + [Product.torchProfilerInstallName, new Set([EnvironmentType.Conda, EnvironmentType.Pixi])], ]); abstract class BaseInstaller implements IBaseInstaller { diff --git a/src/client/common/installer/serviceRegistry.ts b/src/client/common/installer/serviceRegistry.ts index d4d8a05c3a49..1e273ada818c 100644 --- a/src/client/common/installer/serviceRegistry.ts +++ b/src/client/common/installer/serviceRegistry.ts @@ -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, PixiInstaller); serviceManager.addSingleton(IModuleInstaller, CondaInstaller); serviceManager.addSingleton(IModuleInstaller, PipInstaller); serviceManager.addSingleton(IModuleInstaller, PipEnvInstaller); diff --git a/src/client/common/process/pythonEnvironment.ts b/src/client/common/process/pythonEnvironment.ts index 9566f373aa91..cbf898ac5f50 100644 --- a/src/client/common/process/pythonEnvironment.ts +++ b/src/client/common/process/pythonEnvironment.ts @@ -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> = new Map>(); @@ -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 { + 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. diff --git a/src/client/common/process/pythonExecutionFactory.ts b/src/client/common/process/pythonExecutionFactory.ts index fc13e7f2346c..939c91514952 100644 --- a/src/client/common/process/pythonExecutionFactory.ts +++ b/src/client/common/process/pythonExecutionFactory.ts @@ -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, @@ -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 { @@ -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; @@ -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; @@ -139,6 +150,23 @@ export class PythonExecutionFactory implements IPythonExecutionFactory { } return createPythonService(processService, env); } + + public async createPixiExecutionService( + pythonPath: string, + processService: IProcessService, + ): Promise { + 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 { diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 8c872c3113ba..307d3ffe038f 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -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(IsWindows, isWindows()); @@ -161,6 +162,11 @@ export function registerTypes(serviceManager: IServiceManager): void { CondaActivationCommandProvider, TerminalActivationProviders.conda, ); + serviceManager.addSingleton( + ITerminalActivationCommandProvider, + PixiActivationCommandProvider, + TerminalActivationProviders.pixi, + ); serviceManager.addSingleton( ITerminalActivationCommandProvider, PipEnvActivationCommandProvider, diff --git a/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts new file mode 100644 index 000000000000..f9110f6be60c --- /dev/null +++ b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts @@ -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 { + 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 { + 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'; + } +} diff --git a/src/client/common/terminal/helper.ts b/src/client/common/terminal/helper.ts index f1a89df10786..9fcdd98bd289 100644 --- a/src/client/common/terminal/helper.ts +++ b/src/client/common/terminal/helper.ts @@ -50,6 +50,9 @@ export class TerminalHelper implements ITerminalHelper { @inject(ITerminalActivationCommandProvider) @named(TerminalActivationProviders.pipenv) private readonly pipenv: ITerminalActivationCommandProvider, + @inject(ITerminalActivationCommandProvider) + @named(TerminalActivationProviders.pixi) + private readonly pixi: ITerminalActivationCommandProvider, @multiInject(IShellDetector) shellDetectors: IShellDetector[], ) { this.shellDetector = new ShellDetector(this.platform, shellDetectors); @@ -75,7 +78,14 @@ export class TerminalHelper implements ITerminalHelper { resource?: Uri, interpreter?: PythonEnvironment, ): Promise { - const providers = [this.pipenv, this.pyenv, this.bashCShellFish, this.commandPromptAndPowerShell, this.nushell]; + const providers = [ + this.pixi, + this.pipenv, + this.pyenv, + this.bashCShellFish, + this.commandPromptAndPowerShell, + this.nushell, + ]; const promise = this.getActivationCommands(resource || undefined, interpreter, terminalShellType, providers); this.sendTelemetry( terminalShellType, @@ -93,7 +103,7 @@ export class TerminalHelper implements ITerminalHelper { if (this.platform.osType === OSType.Unknown) { return; } - const providers = [this.bashCShellFish, this.commandPromptAndPowerShell, this.nushell]; + const providers = [this.pixi, this.bashCShellFish, this.commandPromptAndPowerShell, this.nushell]; const promise = this.getActivationCommands(resource, interpreter, shell, providers); this.sendTelemetry( shell, diff --git a/src/client/common/terminal/types.ts b/src/client/common/terminal/types.ts index 303188682378..49f42e7c19f6 100644 --- a/src/client/common/terminal/types.ts +++ b/src/client/common/terminal/types.ts @@ -15,6 +15,7 @@ export enum TerminalActivationProviders { pyenv = 'pyenv', conda = 'conda', pipenv = 'pipenv', + pixi = 'pixi', } export enum TerminalShellType { powershell = 'powershell', diff --git a/src/client/common/types.ts b/src/client/common/types.ts index d4a0921140ec..754e08004213 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -167,6 +167,7 @@ export interface IPythonSettings { readonly condaPath: string; readonly pipenvPath: string; readonly poetryPath: string; + readonly pixiToolPath: string; readonly devOptions: string[]; readonly testing: ITestingSettings; readonly autoComplete: IAutoCompleteSettings; diff --git a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 5d01cbaafb7a..0a663ac9f0d3 100644 --- a/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -75,6 +75,7 @@ export namespace EnvGroups { export const Venv = 'Venv'; export const Poetry = 'Poetry'; export const Hatch = 'Hatch'; + export const Pixi = 'Pixi'; export const VirtualEnvWrapper = 'VirtualEnvWrapper'; export const ActiveState = 'ActiveState'; export const Recommended = Common.recommended; diff --git a/src/client/pythonEnvironments/base/info/envKind.ts b/src/client/pythonEnvironments/base/info/envKind.ts index 77ed7f22533e..08f4ce55d464 100644 --- a/src/client/pythonEnvironments/base/info/envKind.ts +++ b/src/client/pythonEnvironments/base/info/envKind.ts @@ -16,6 +16,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string { [PythonEnvKind.Pyenv, 'pyenv'], [PythonEnvKind.Poetry, 'Poetry'], [PythonEnvKind.Hatch, 'Hatch'], + [PythonEnvKind.Pixi, 'Pixi'], [PythonEnvKind.Custom, 'custom'], // For now we treat OtherGlobal like Unknown. [PythonEnvKind.Venv, 'venv'], @@ -47,6 +48,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string { * 4. Pyenv * 5. Poetry * 6. Hatch + * 7. 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. @@ -59,6 +61,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, diff --git a/src/client/pythonEnvironments/base/info/index.ts b/src/client/pythonEnvironments/base/info/index.ts index 1806109142fd..4547e7606308 100644 --- a/src/client/pythonEnvironments/base/info/index.ts +++ b/src/client/pythonEnvironments/base/info/index.ts @@ -16,6 +16,7 @@ export enum PythonEnvKind { Pyenv = 'global-pyenv', Poetry = 'poetry', Hatch = 'hatch', + Pixi = 'pixi', ActiveState = 'activestate', Custom = 'global-custom', OtherGlobal = 'global-other', @@ -46,6 +47,7 @@ export interface EnvPathType { export const virtualEnvKinds = [ PythonEnvKind.Poetry, PythonEnvKind.Hatch, + PythonEnvKind.Pixi, PythonEnvKind.Pipenv, PythonEnvKind.Venv, PythonEnvKind.VirtualEnvWrapper, diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts new file mode 100644 index 000000000000..7cdc78ec6f10 --- /dev/null +++ b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +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 { FSWatcherKind, FSWatchingLocator } from './fsWatchingLocator'; + +/** + * Returns all virtual environment locations to look for in a workspace. + */ +async function getVirtualEnvDirs(root: string): Promise { + const pixi = await Pixi.getPixi(); + const envDirs = (await pixi?.getEnvList(root)) ?? []; + return asyncFilter(envDirs, pathExists); +} + +/** + * Returns all virtual environment locations to look for in a workspace. + */ +function getVirtualEnvRootDirs(root: string): string[] { + return [path.join(path.join(root, '.pixi'), 'envs')]; +} + +export class PixiLocator extends FSWatchingLocator { + public readonly providerId: string = 'pixi'; + + public constructor(private readonly root: string) { + super( + async () => getVirtualEnvRootDirs(this.root), + async () => PythonEnvKind.Pixi, + { + // Note detecting kind of virtual env depends on the file structure around the + // executable, so we need to wait before attempting to detect it. + delayOnCreated: 1000, + }, + FSWatcherKind.Workspace, + ); + } + + protected doIterEnvs(): IPythonEnvsIterator { + 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); + } +} diff --git a/src/client/pythonEnvironments/common/environmentIdentifier.ts b/src/client/pythonEnvironments/common/environmentIdentifier.ts index 880eed52598c..89ff84823673 100644 --- a/src/client/pythonEnvironments/common/environmentIdentifier.ts +++ b/src/client/pythonEnvironments/common/environmentIdentifier.ts @@ -16,6 +16,7 @@ import { } from './environmentManagers/simplevirtualenvs'; import { isMicrosoftStoreEnvironment } from './environmentManagers/microsoftStoreEnv'; import { isActiveStateEnvironment } from './environmentManagers/activestate'; +import { isPixiEnvironment } from './environmentManagers/pixi'; const notImplemented = () => Promise.resolve(false); @@ -31,6 +32,7 @@ function getIdentifiers(): Map Promise 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); diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts new file mode 100644 index 000000000000..f3d6dc3e081e --- /dev/null +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as path from 'path'; +import { readJSON } from 'fs-extra'; +import { OSType, getOSType, getUserHomeDir } from '../../../common/utils/platform'; +import { exec, getPythonSetting, onDidChangePythonSetting, pathExists, pathExistsSync } from '../externalDependencies'; +import { cache } from '../../../common/utils/decorators'; +import { isTestExecution } from '../../../common/constants'; +import { traceError, traceVerbose, traceWarn } from '../../../logging'; +import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; + +export const PIXITOOLPATH_SETTING_KEY = 'pixiToolPath'; + +// 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?: PixiProjectInfo /* eslint-disable-line camelcase */; + + 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 type PixiProjectInfo = { + manifest_path: string; // eslint-disable-line camelcase + last_updated: string; // eslint-disable-line camelcase + pixi_folder_size?: number; // eslint-disable-line camelcase + version: string; +}; + +export type PixiEnvMetadata = { + manifest_path: string; // eslint-disable-line camelcase + pixi_version: string; // eslint-disable-line camelcase + environment_name: string; // eslint-disable-line camelcase +}; + +export async function isPixiEnvironment(interpreterPath: string): Promise { + const prefix = getPrefixFromInterpreterPath(interpreterPath); + return ( + pathExists(path.join(prefix, 'conda-meta/pixi')) || pathExists(path.join(prefix, 'conda-meta/pixi_env_prefix')) + ); +} + +/** + * Returns the path to the environment directory based on the interpreter path. + */ +export function getPrefixFromInterpreterPath(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: Promise | 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. + */ + constructor(public readonly command: string) { + onDidChangePythonSetting(PIXITOOLPATH_SETTING_KEY, () => { + Pixi.pixiPromise = undefined; + }); + } + + /** + * 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(): Promise { + if (Pixi.pixiPromise === undefined || isTestExecution()) { + Pixi.pixiPromise = Pixi.locate(); + } + return Pixi.pixiPromise; + } + + private static async locate(): Promise { + // First thing this method awaits on should be pixi command execution, hence perform all operations + // before that synchronously. + + traceVerbose(`Getting pixi`); + // Produce a list of candidate binaries to be probed by exec'ing them. + function* getCandidates() { + // Read the pixi location from the settings. + try { + const customPixiToolPath = getPythonSetting(PIXITOOLPATH_SETTING_KEY); + if (customPixiToolPath && customPixiToolPath !== 'pixi') { + // If user has specified a custom pixi path, use it first. + yield customPixiToolPath; + } + } 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 defaultpixiToolPath = path.join(home, '.pixi', 'bin', 'pixi'); + if (pathExistsSync(defaultpixiToolPath)) { + yield defaultpixiToolPath; + } + } + } + + // Probe the candidates, and pick the first one that exists and does what we need. + for (const pixiToolPath of getCandidates()) { + traceVerbose(`Probing pixi binary: ${pixiToolPath}`); + const pixi = new Pixi(pixiToolPath); + const pixiVersion = await pixi.getVersion(); + if (pixiVersion !== undefined) { + traceVerbose(`Found pixi ${pixiVersion} via filesystem probing: ${pixiToolPath}`); + return pixi; + } + traceVerbose(`Failed to find pixi: ${pixiToolPath}`); + } + + // Didn't find anything. + traceVerbose(`No pixi binary found`); + return undefined; + } + + /** + * Retrieves list of Python environments known to this pixi for the specified directory. + * + * Corresponds to "pixi info --json" and extracting the environments. Swallows errors if any. + */ + public async getEnvList(cwd: string): Promise { + const pixiInfo = await this.getPixiInfo(cwd); + // eslint-disable-next-line camelcase + return pixiInfo?.environments_info.map((env) => env.prefix); + } + + /** + * Method that runs `pixi info` and returns the result. The value is cached for "only" 1 second + * because the output changes if the project manifest is modified. + */ + @cache(1_000, true, 1_000) + public async getPixiInfo(cwd: string): Promise { + const infoOutput = await exec(this.command, ['info', '--json'], { + cwd, + throwOnStdErr: false, + }).catch(traceError); + if (!infoOutput) { + return undefined; + } + + const pixiInfo: PixiInfo = JSON.parse(infoOutput.stdout); + return pixiInfo; + } + + /** + * Runs `pixi --version` and returns the version part of the output. + */ + @cache(30_000, true, 10_000) + public async getVersion(): Promise { + const versionOutput = await exec(this.command, ['--version'], { + throwOnStdErr: false, + }).catch(traceError); + if (!versionOutput) { + return undefined; + } + + return versionOutput.stdout.split(' ')[1].trim(); + } + + /** + * Returns the command line arguments to run `python` within a specific pixi environment. + * @param manifestPath The path to the manifest file used by pixi. + * @param envName The name of the environment in the pixi project + * @param isolatedFlag Whether to add `-I` to the python invocation. + * @returns A list of arguments that can be passed to exec. + */ + public getRunPythonArgs(manifestPath: string, envName?: string, isolatedFlag = false): string[] { + let python = [this.command, 'run', '--manifest-path', manifestPath]; + if (isNonDefaultPixiEnvironmentName(envName)) { + python = python.concat(['--environment', envName]); + } + + python.push('python'); + if (isolatedFlag) { + python.push('-I'); + } + return [...python, OUTPUT_MARKER_SCRIPT]; + } + + /** + * Starting from Pixi 0.24.0, each environment has a special file that records some information + * about which manifest created the environment. + * + * @param envDir The root directory (or prefix) of a conda environment + */ + @cache(5_000, true, 10_000) + // eslint-disable-next-line class-methods-use-this + async getPixiEnvironmentMetadata(envDir: string): Promise { + const pixiPath = path.join(envDir, 'conda-meta/pixi'); + const result: PixiEnvMetadata | undefined = await readJSON(pixiPath).catch(traceVerbose); + return result; + } +} + +export type PixiEnvironmentInfo = { + interpreterPath: string; + pixi: Pixi; + pixiVersion: string; + manifestPath: string; + envName?: string; +}; + +/** + * Given the location of an interpreter, try to deduce information about the environment in which it + * resides. + * @param interpreterPath The full path to the interpreter. + * @param pixi Optionally a pixi instance. If this is not specified it will be located. + * @returns Information about the pixi environment. + */ +export async function getPixiEnvironmentFromInterpreter( + interpreterPath: string, + pixi?: Pixi, +): Promise { + if (!interpreterPath) { + return undefined; + } + + const prefix = getPrefixFromInterpreterPath(interpreterPath); + + // Find the pixi executable for the project + pixi = pixi || (await Pixi.getPixi()); + if (!pixi) { + traceWarn(`could not find a pixi interpreter for the interpreter at ${interpreterPath}`); + return undefined; + } + + // Check if the environment has pixi metadata that we can source. + const metadata = await pixi.getPixiEnvironmentMetadata(prefix); + if (metadata !== undefined) { + return { + interpreterPath, + pixi, + pixiVersion: metadata.pixi_version, + manifestPath: metadata.manifest_path, + envName: metadata.environment_name, + }; + } + + // Otherwise, we'll have to try to deduce this information. + + // Usually the pixi environments are stored under `/.pixi/envs//`. So, + // we walk backwards to determine the project directory. + const envName = path.basename(prefix); + const envsDir = path.dirname(prefix); + const dotPixiDir = path.dirname(envsDir); + const pixiProjectDir = path.dirname(dotPixiDir); + + // Invoke pixi to get information about the pixi project + const pixiInfo = await pixi.getPixiInfo(pixiProjectDir); + if (!pixiInfo || !pixiInfo.project_info) { + traceWarn(`failed to determine pixi project information for the interpreter at ${interpreterPath}`); + return undefined; + } + + return { + interpreterPath, + pixi, + pixiVersion: pixiInfo.version, + manifestPath: pixiInfo.project_info.manifest_path, + envName, + }; +} + +/** + * Returns true if the given environment name is *not* the default environment. + */ +export function isNonDefaultPixiEnvironmentName(envName?: string): envName is string { + return envName !== undefined && envName !== 'default'; +} diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index d93d232242c1..33a6136d35a5 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -39,6 +39,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'; import { NativeLocator } from './base/locators/lowLevel/nativeLocator'; import { getConfiguration } from '../common/vscodeApis/workspaceApis'; @@ -206,6 +207,7 @@ function createWorkspaceLocator(ext: ExtensionState): WorkspaceLocators { new WorkspaceVirtualEnvironmentLocator(root.fsPath), new PoetryLocator(root.fsPath), new HatchLocator(root.fsPath), + new PixiLocator(root.fsPath), new CustomWorkspaceLocator(root.fsPath), ], // Add an ILocator factory func here for each kind of workspace-rooted locator. diff --git a/src/client/pythonEnvironments/info/index.ts b/src/client/pythonEnvironments/info/index.ts index 716d4bcd938f..08310767914a 100644 --- a/src/client/pythonEnvironments/info/index.ts +++ b/src/client/pythonEnvironments/info/index.ts @@ -20,6 +20,7 @@ export enum EnvironmentType { MicrosoftStore = 'MicrosoftStore', Poetry = 'Poetry', Hatch = 'Hatch', + Pixi = 'Pixi', VirtualEnvWrapper = 'VirtualEnvWrapper', ActiveState = 'ActiveState', Global = 'Global', @@ -28,7 +29,7 @@ export enum EnvironmentType { /** * These envs are only created for a specific workspace, which we're able to detect. */ -export const workspaceVirtualEnvTypes = [EnvironmentType.Poetry, EnvironmentType.Pipenv]; +export const workspaceVirtualEnvTypes = [EnvironmentType.Poetry, EnvironmentType.Pipenv, EnvironmentType.Pixi]; export const virtualEnvTypes = [ ...workspaceVirtualEnvTypes, @@ -48,6 +49,7 @@ export enum ModuleInstallerType { Pip = 'Pip', Poetry = 'Poetry', Pipenv = 'Pipenv', + Pixi = 'Pixi', } /** @@ -123,6 +125,9 @@ export function getEnvironmentTypeName(environmentType: EnvironmentType): string case EnvironmentType.Hatch: { return 'Hatch'; } + case EnvironmentType.Pixi: { + return 'pixi'; + } case EnvironmentType.VirtualEnvWrapper: { return 'virtualenvwrapper'; } diff --git a/src/client/pythonEnvironments/legacyIOC.ts b/src/client/pythonEnvironments/legacyIOC.ts index 4ef0894a470d..9d161f8b1b9f 100644 --- a/src/client/pythonEnvironments/legacyIOC.ts +++ b/src/client/pythonEnvironments/legacyIOC.ts @@ -39,6 +39,7 @@ const convertedKinds = new Map( [PythonEnvKind.Pipenv]: EnvironmentType.Pipenv, [PythonEnvKind.Poetry]: EnvironmentType.Poetry, [PythonEnvKind.Hatch]: EnvironmentType.Hatch, + [PythonEnvKind.Pixi]: EnvironmentType.Pixi, [PythonEnvKind.Venv]: EnvironmentType.Venv, [PythonEnvKind.VirtualEnvWrapper]: EnvironmentType.VirtualEnvWrapper, [PythonEnvKind.ActiveState]: EnvironmentType.ActiveState, diff --git a/src/test/common/configSettings/configSettings.unit.test.ts b/src/test/common/configSettings/configSettings.unit.test.ts index 83b5b4a3d524..c4389629e0ec 100644 --- a/src/test/common/configSettings/configSettings.unit.test.ts +++ b/src/test/common/configSettings/configSettings.unit.test.ts @@ -83,6 +83,7 @@ suite('Python Settings', async () => { 'pipenvPath', 'envFile', 'poetryPath', + 'pixiToolPath', 'defaultInterpreterPath', ]) { config @@ -141,6 +142,7 @@ suite('Python Settings', async () => { 'pipenvPath', 'envFile', 'poetryPath', + 'pixiToolPath', 'defaultInterpreterPath', ].forEach(async (settingName) => { testIfValueIsUpdated(settingName, 'stringValue'); diff --git a/src/test/common/terminals/activation.conda.unit.test.ts b/src/test/common/terminals/activation.conda.unit.test.ts index 84e4bffacfc1..39bf58a9a36b 100644 --- a/src/test/common/terminals/activation.conda.unit.test.ts +++ b/src/test/common/terminals/activation.conda.unit.test.ts @@ -31,6 +31,7 @@ import { getNamesAndValues } from '../../../client/common/utils/enum'; import { IComponentAdapter, ICondaService } from '../../../client/interpreter/contracts'; import { InterpreterService } from '../../../client/interpreter/interpreterService'; import { IServiceContainer } from '../../../client/ioc/types'; +import { PixiActivationCommandProvider } from '../../../client/common/terminal/environmentActivationProviders/pixiActivationProvider'; suite('Terminal Environment Activation conda', () => { let terminalHelper: TerminalHelper; @@ -114,6 +115,7 @@ suite('Terminal Environment Activation conda', () => { mock(Nushell), mock(PyEnvActivationCommandProvider), mock(PipEnvActivationCommandProvider), + mock(PixiActivationCommandProvider), [], ); }); diff --git a/src/test/common/terminals/helper.unit.test.ts b/src/test/common/terminals/helper.unit.test.ts index b6a8d44ac030..e4a0ab9bd3e8 100644 --- a/src/test/common/terminals/helper.unit.test.ts +++ b/src/test/common/terminals/helper.unit.test.ts @@ -32,6 +32,7 @@ import { IComponentAdapter } from '../../../client/interpreter/contracts'; import { InterpreterService } from '../../../client/interpreter/interpreterService'; import { IServiceContainer } from '../../../client/ioc/types'; import { EnvironmentType, PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { PixiActivationCommandProvider } from '../../../client/common/terminal/environmentActivationProviders/pixiActivationProvider'; suite('Terminal Service helpers', () => { let helper: TerminalHelper; @@ -46,6 +47,7 @@ suite('Terminal Service helpers', () => { let nushellActivationProvider: ITerminalActivationCommandProvider; let pyenvActivationProvider: ITerminalActivationCommandProvider; let pipenvActivationProvider: ITerminalActivationCommandProvider; + let pixiActivationProvider: ITerminalActivationCommandProvider; let pythonSettings: PythonSettings; let shellDetectorIdentifyTerminalShell: sinon.SinonStub<[(Terminal | undefined)?], TerminalShellType>; let mockDetector: IShellDetector; @@ -72,6 +74,7 @@ suite('Terminal Service helpers', () => { nushellActivationProvider = mock(Nushell); pyenvActivationProvider = mock(PyEnvActivationCommandProvider); pipenvActivationProvider = mock(PipEnvActivationCommandProvider); + pixiActivationProvider = mock(PixiActivationCommandProvider); pythonSettings = mock(PythonSettings); shellDetectorIdentifyTerminalShell = sinon.stub(ShellDetector.prototype, 'identifyTerminalShell'); helper = new TerminalHelper( @@ -86,6 +89,7 @@ suite('Terminal Service helpers', () => { instance(nushellActivationProvider), instance(pyenvActivationProvider), instance(pipenvActivationProvider), + instance(pixiActivationProvider), [instance(mockDetector)], ); } diff --git a/src/test/pythonEnvironments/base/info/envKind.unit.test.ts b/src/test/pythonEnvironments/base/info/envKind.unit.test.ts index 997c8a08c7f2..6d0866754330 100644 --- a/src/test/pythonEnvironments/base/info/envKind.unit.test.ts +++ b/src/test/pythonEnvironments/base/info/envKind.unit.test.ts @@ -14,6 +14,7 @@ const KIND_NAMES: [PythonEnvKind, string][] = [ [PythonEnvKind.Pyenv, 'pyenv'], [PythonEnvKind.Poetry, 'poetry'], [PythonEnvKind.Hatch, 'hatch'], + [PythonEnvKind.Pixi, 'pixi'], [PythonEnvKind.Custom, 'customGlobal'], [PythonEnvKind.OtherGlobal, 'otherGlobal'], [PythonEnvKind.Venv, 'venv'], diff --git a/src/test/pythonEnvironments/base/locators/lowLevel/pixiLocator.unit.test.ts b/src/test/pythonEnvironments/base/locators/lowLevel/pixiLocator.unit.test.ts new file mode 100644 index 000000000000..6bb147b41832 --- /dev/null +++ b/src/test/pythonEnvironments/base/locators/lowLevel/pixiLocator.unit.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as sinon from 'sinon'; +import * as path from 'path'; +import { PixiLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/pixiLocator'; +import * as externalDependencies from '../../../../../client/pythonEnvironments/common/externalDependencies'; +import * as platformUtils from '../../../../../client/common/utils/platform'; +import { makeExecHandler, projectDirs } from '../../../common/environmentManagers/pixi.unit.test'; +import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils'; +import { createBasicEnv } from '../../common'; +import { PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info'; +import { assertBasicEnvsEqual } from '../envTestUtils'; + +suite('Pixi Locator', () => { + let exec: sinon.SinonStub; + let getPythonSetting: sinon.SinonStub; + let getOSType: sinon.SinonStub; + let locator: PixiLocator; + + suiteSetup(() => { + getPythonSetting = sinon.stub(externalDependencies, 'getPythonSetting'); + getPythonSetting.returns('pixi'); + getOSType = sinon.stub(platformUtils, 'getOSType'); + exec = sinon.stub(externalDependencies, 'exec'); + }); + + suiteTeardown(() => sinon.restore()); + + suite('iterEnvs()', () => { + interface TestArgs { + projectDir: string; + osType: platformUtils.OSType; + pythonBin: string; + } + + const testProject = async ({ projectDir, osType, pythonBin }: TestArgs) => { + getOSType.returns(osType); + + locator = new PixiLocator(projectDir); + exec.callsFake(makeExecHandler({ pixiPath: 'pixi', cwd: projectDir })); + + const iterator = locator.iterEnvs(); + const actualEnvs = await getEnvs(iterator); + + const envPath = path.join(projectDir, '.pixi', 'envs', 'default'); + const expectedEnvs = [ + createBasicEnv(PythonEnvKind.Pixi, path.join(envPath, pythonBin), undefined, envPath), + ]; + assertBasicEnvsEqual(actualEnvs, expectedEnvs); + }; + + test('project with only the default env', () => + testProject({ + projectDir: projectDirs.nonWindows.path, + osType: platformUtils.OSType.Linux, + pythonBin: 'bin/python', + })); + test('project with only the default env on Windows', () => + testProject({ + projectDir: projectDirs.windows.path, + osType: platformUtils.OSType.Windows, + pythonBin: 'python.exe', + })); + + test('project with multiple environments', async () => { + getOSType.returns(platformUtils.OSType.Linux); + + exec.callsFake(makeExecHandler({ pixiPath: 'pixi', cwd: projectDirs.multiEnv.path })); + + locator = new PixiLocator(projectDirs.multiEnv.path); + const iterator = locator.iterEnvs(); + const actualEnvs = await getEnvs(iterator); + + const expectedEnvs = [ + createBasicEnv( + PythonEnvKind.Pixi, + path.join(projectDirs.multiEnv.info.environments_info[1].prefix, 'bin/python'), + undefined, + projectDirs.multiEnv.info.environments_info[1].prefix, + ), + createBasicEnv( + PythonEnvKind.Pixi, + path.join(projectDirs.multiEnv.info.environments_info[2].prefix, 'bin/python'), + undefined, + projectDirs.multiEnv.info.environments_info[2].prefix, + ), + ]; + assertBasicEnvsEqual(actualEnvs, expectedEnvs); + }); + }); +}); diff --git a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts new file mode 100644 index 000000000000..d6b283c69fd3 --- /dev/null +++ b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts @@ -0,0 +1,139 @@ +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { ExecutionResult, ShellOptions } from '../../../../client/common/process/types'; +import * as externalDependencies from '../../../../client/pythonEnvironments/common/externalDependencies'; +import { TEST_LAYOUT_ROOT } from '../commonTestConstants'; +import { Pixi } from '../../../../client/pythonEnvironments/common/environmentManagers/pixi'; + +export type PixiCommand = { cmd: 'info --json' } | { cmd: '--version' } | { cmd: null }; + +const textPixiDir = path.join(TEST_LAYOUT_ROOT, 'pixi'); +export const projectDirs = { + windows: { + path: path.join(textPixiDir, 'windows'), + info: { + environments_info: [ + { + prefix: path.join(textPixiDir, 'windows', '.pixi', 'envs', 'default'), + }, + ], + }, + }, + nonWindows: { + path: path.join(textPixiDir, 'non-windows'), + info: { + environments_info: [ + { + prefix: path.join(textPixiDir, 'non-windows', '.pixi', 'envs', 'default'), + }, + ], + }, + }, + multiEnv: { + path: path.join(textPixiDir, 'multi-env'), + info: { + environments_info: [ + { + prefix: path.join(textPixiDir, 'multi-env', '.pixi', 'envs', 'default'), + }, + { + prefix: path.join(textPixiDir, 'multi-env', '.pixi', 'envs', 'py310'), + }, + { + prefix: path.join(textPixiDir, 'multi-env', '.pixi', 'envs', 'py311'), + }, + ], + }, + }, +}; + +/** + * Convert the command line arguments into a typed command. + */ +export function pixiCommand(args: string[]): PixiCommand { + if (args[0] === '--version') { + return { cmd: '--version' }; + } + + if (args.length < 2) { + return { cmd: null }; + } + if (args[0] === 'info' && args[1] === '--json') { + return { cmd: 'info --json' }; + } + return { cmd: null }; +} +interface VerifyOptions { + pixiPath?: string; + cwd?: string; +} + +export function makeExecHandler(verify: VerifyOptions = {}) { + return async (file: string, args: string[], options: ShellOptions): Promise> => { + /// Verify that the executable path is indeed the one we expect it to be + if (verify.pixiPath && file !== verify.pixiPath) { + throw new Error('Command failed: not the correct pixi path'); + } + + const cmd = pixiCommand(args); + if (cmd.cmd === '--version') { + return { stdout: 'pixi 0.24.1' }; + } + + /// Verify that the working directory is the expected one + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); + if (verify.cwd) { + if (!cwd || !externalDependencies.arePathsSame(cwd, verify.cwd)) { + throw new Error(`Command failed: not the correct path, expected: ${verify.cwd}, got: ${cwd}`); + } + } + + /// Convert the command into a single string + if (cmd.cmd === 'info --json') { + const project = Object.values(projectDirs).find((p) => cwd?.startsWith(p.path)); + if (!project) { + throw new Error('Command failed: could not find project'); + } + return { stdout: JSON.stringify(project.info) }; + } + + throw new Error(`Command failed: unknown command ${args}`); + }; +} + +suite('Pixi binary is located correctly', async () => { + let exec: sinon.SinonStub; + let getPythonSetting: sinon.SinonStub; + + setup(() => { + getPythonSetting = sinon.stub(externalDependencies, 'getPythonSetting'); + exec = sinon.stub(externalDependencies, 'exec'); + }); + + teardown(() => { + sinon.restore(); + }); + + const testPath = async (pixiPath: string, verify = true) => { + getPythonSetting.returns(pixiPath); + // If `verify` is false, don’t verify that the command has been called with that path + exec.callsFake(makeExecHandler(verify ? { pixiPath } : undefined)); + const pixi = await Pixi.getPixi(); + expect(pixi?.command).to.equal(pixiPath); + }; + + test('Return a Pixi instance in an empty directory', () => testPath('pixiPath', false)); + test('When user has specified a valid Pixi path, use it', () => testPath('path/to/pixi/binary')); + // 'pixi' is the default value + test('When user hasn’t specified a path, use Pixi on PATH if available', () => testPath('pixi')); + + test('Return undefined if Pixi cannot be found', async () => { + getPythonSetting.returns('pixi'); + exec.callsFake((_file: string, _args: string[], _options: ShellOptions) => + Promise.reject(new Error('Command failed')), + ); + const pixi = await Pixi.getPixi(); + expect(pixi?.command).to.equal(undefined); + }); +}); diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/bin/python b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/bin/python new file mode 100644 index 000000000000..a37b666d049e --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/bin/python @@ -0,0 +1 @@ +Not real python exe diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/conda-meta/pixi b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/conda-meta/pixi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/bin/python b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/bin/python new file mode 100644 index 000000000000..a37b666d049e --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/bin/python @@ -0,0 +1 @@ +Not real python exe diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/conda-meta/pixi b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/conda-meta/pixi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/python b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/python new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/pixi.toml b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/pixi.toml new file mode 100644 index 000000000000..9b93e638e9ab --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/pixi.toml @@ -0,0 +1,14 @@ +[project] +name = "multi-env" +channels = ["conda-forge"] +platforms = ["win-64"] + +[feature.py310.dependencies] +python = "~=3.10" + +[feature.py311.dependencies] +python = "~=3.11" + +[environments] +py310 = ["py310"] +py311 = ["py311"] diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/bin/python b/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/bin/python new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/conda-meta/pixi b/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/conda-meta/pixi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/pixi.toml b/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/pixi.toml new file mode 100644 index 000000000000..f11ab3b42360 --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/pixi.toml @@ -0,0 +1,11 @@ +[project] +name = "non-windows" +version = "0.1.0" +description = "Add a short description here" +authors = ["Bas Zalmstra "] +channels = ["conda-forge"] +platforms = ["win-64"] + +[tasks] + +[dependencies] diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/windows/.pixi/envs/default/python.exe b/src/test/pythonEnvironments/common/envlayouts/pixi/windows/.pixi/envs/default/python.exe new file mode 100644 index 000000000000..a37b666d049e --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/windows/.pixi/envs/default/python.exe @@ -0,0 +1 @@ +Not real python exe diff --git a/src/test/pythonEnvironments/common/envlayouts/pixi/windows/pixi.toml b/src/test/pythonEnvironments/common/envlayouts/pixi/windows/pixi.toml new file mode 100644 index 000000000000..1341496c5590 --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/pixi/windows/pixi.toml @@ -0,0 +1,12 @@ +[project] +name = "windows" +version = "0.1.0" +description = "Add a short description here" +authors = ["Bas Zalmstra "] +channels = ["conda-forge"] +platforms = ["win-64"] + +[tasks] + +[dependencies] +python = "~=3.8.0"