From 96b40a1b8914226c8ef68f28c9f22bc5dceac44f Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Mon, 26 Feb 2024 15:37:25 +0100 Subject: [PATCH 01/10] feat: add locator for pixi environments --- package.json | 6 + package.nls.json | 1 + resources/report_issue_user_settings.json | 1 + src/client/common/configSettings.ts | 4 + src/client/common/types.ts | 1 + .../pythonEnvironments/base/info/envKind.ts | 3 + .../pythonEnvironments/base/info/index.ts | 2 + .../base/locators/lowLevel/pixiLocator.ts | 57 ++++++ .../common/environmentIdentifier.ts | 2 + .../common/environmentManagers/pixi.ts | 181 ++++++++++++++++++ src/client/pythonEnvironments/index.ts | 2 + .../configSettings.unit.test.ts | 2 + .../base/info/envKind.unit.test.ts | 1 + 13 files changed, 263 insertions(+) create mode 100644 src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts create mode 100644 src/client/pythonEnvironments/common/environmentManagers/pixi.ts diff --git a/package.json b/package.json index efe8cba4f5d6..1949a3912ef6 100644 --- a/package.json +++ b/package.json @@ -652,6 +652,12 @@ "scope": "machine-overridable", "type": "string" }, + "python.pixiPath": { + "default": "pixi", + "description": "%python.pixiPath.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 5a4c44a78191..13157ac29917 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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`.", diff --git a/resources/report_issue_user_settings.json b/resources/report_issue_user_settings.json index eea4ca007da6..5dd91923e102 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", + "pixiPath": "placeholder", "devOptions": false, "globalModuleInstallation": false, "languageServer": true, diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 88a5007467bb..7f06626050f7 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 pixiPath = ''; + public devOptions: string[] = []; public autoComplete!: IAutoCompleteSettings; @@ -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('poetryPath'))!; this.poetryPath = poetryPath && poetryPath.length > 0 ? getAbsolutePath(poetryPath, workspaceRoot) : poetryPath; + const pixiPath = systemVariables.resolveAny(pythonSettings.get('pixiPath'))!; + this.pixiPath = pixiPath && pixiPath.length > 0 ? getAbsolutePath(pixiPath, workspaceRoot) : pixiPath; this.interpreter = pythonSettings.get('interpreter') ?? { infoVisibility: 'onPythonRelated', diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 67fcf5c7b700..ab76d8a1df61 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 pixiPath: string; readonly devOptions: string[]; readonly testing: ITestingSettings; readonly autoComplete: IAutoCompleteSettings; diff --git a/src/client/pythonEnvironments/base/info/envKind.ts b/src/client/pythonEnvironments/base/info/envKind.ts index ff53a57d2f45..3e8f6dd54b98 100644 --- a/src/client/pythonEnvironments/base/info/envKind.ts +++ b/src/client/pythonEnvironments/base/info/envKind.ts @@ -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'], @@ -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. @@ -57,6 +59,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string { export function getPrioritizedEnvKinds(): PythonEnvKind[] { return [ PythonEnvKind.Pyenv, + PythonEnvKind.Pixi, 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 e55031fe8078..2d8f307e9ec2 100644 --- a/src/client/pythonEnvironments/base/info/index.ts +++ b/src/client/pythonEnvironments/base/info/index.ts @@ -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', @@ -44,6 +45,7 @@ export interface EnvPathType { export const virtualEnvKinds = [ PythonEnvKind.Poetry, + 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..69324efb5b95 --- /dev/null +++ b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts @@ -0,0 +1,57 @@ +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 { + 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 { + 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 2dbc8b2b93d9..8ca9be26b1e5 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'; function getIdentifiers(): Map Promise> { const notImplemented = () => Promise.resolve(false); @@ -30,6 +31,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..2315f02fa876 --- /dev/null +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -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 { + // We want to verify the following layout + // project + // |__ pixi.toml <-- check if this exists + // |__ .pixi + // |__ envs + // |__ + // |__ 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> = new Map>(); + + /** + * 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 { + 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 { + // 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('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 { + 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 { + 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); + } +} diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index 5a5fceffa693..fbdf8f6336cf 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -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).' @@ -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. diff --git a/src/test/common/configSettings/configSettings.unit.test.ts b/src/test/common/configSettings/configSettings.unit.test.ts index 83b5b4a3d524..35865d9e0395 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', + 'pixiPath', 'defaultInterpreterPath', ]) { config @@ -141,6 +142,7 @@ suite('Python Settings', async () => { 'pipenvPath', 'envFile', 'poetryPath', + 'pixiPath', 'defaultInterpreterPath', ].forEach(async (settingName) => { testIfValueIsUpdated(settingName, 'stringValue'); diff --git a/src/test/pythonEnvironments/base/info/envKind.unit.test.ts b/src/test/pythonEnvironments/base/info/envKind.unit.test.ts index fdf174b4c551..3327847cad8a 100644 --- a/src/test/pythonEnvironments/base/info/envKind.unit.test.ts +++ b/src/test/pythonEnvironments/base/info/envKind.unit.test.ts @@ -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'], From de4264353746eed4d9203e10e2b2f692b2a8d0a5 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Mon, 26 Feb 2024 15:48:44 +0100 Subject: [PATCH 02/10] fix: license and comments --- src/client/pythonEnvironments/base/info/envKind.ts | 2 +- .../pythonEnvironments/base/locators/lowLevel/pixiLocator.ts | 3 +++ src/test/.vscode/settings.json | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/pythonEnvironments/base/info/envKind.ts b/src/client/pythonEnvironments/base/info/envKind.ts index 3e8f6dd54b98..b9540d28594d 100644 --- a/src/client/pythonEnvironments/base/info/envKind.ts +++ b/src/client/pythonEnvironments/base/info/envKind.ts @@ -59,7 +59,7 @@ export function getKindDisplayName(kind: PythonEnvKind): string { export function getPrioritizedEnvKinds(): PythonEnvKind[] { return [ PythonEnvKind.Pyenv, - PythonEnvKind.Pixi, + PythonEnvKind.Pixi, // Placed here since Pixi environments are essentially Conda envs PythonEnvKind.Conda, PythonEnvKind.MicrosoftStore, PythonEnvKind.Pipenv, diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts index 69324efb5b95..b3aafb0028ce 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts @@ -1,3 +1,6 @@ +// 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'; diff --git a/src/test/.vscode/settings.json b/src/test/.vscode/settings.json index faeb48ffa29c..cd2b4152591d 100644 --- a/src/test/.vscode/settings.json +++ b/src/test/.vscode/settings.json @@ -13,5 +13,6 @@ "python.linting.banditEnabled": false, // Don't set this to `Pylance`, for CI we want to use the LS that ships with the extension. "python.languageServer": "Jedi", - "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe" + "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe", + "python.defaultInterpreterPath": "python" } From deb36bb45a3573885d0ab3009159c63c480b46e2 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 27 Feb 2024 08:58:13 +0100 Subject: [PATCH 03/10] fix: accidential change --- src/test/.vscode/settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/.vscode/settings.json b/src/test/.vscode/settings.json index cd2b4152591d..faeb48ffa29c 100644 --- a/src/test/.vscode/settings.json +++ b/src/test/.vscode/settings.json @@ -13,6 +13,5 @@ "python.linting.banditEnabled": false, // Don't set this to `Pylance`, for CI we want to use the LS that ships with the extension. "python.languageServer": "Jedi", - "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe", - "python.defaultInterpreterPath": "python" + "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe" } From b430cf7fbc3802f90a5203bb690a3bafc624cb7b Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 29 Feb 2024 11:35:01 +0100 Subject: [PATCH 04/10] fix: rename pixiPath to pixiToolPath --- package.json | 4 ++-- package.nls.json | 2 +- resources/report_issue_user_settings.json | 2 +- src/client/common/configSettings.ts | 7 +++--- src/client/common/types.ts | 2 +- .../common/environmentManagers/pixi.ts | 22 +++++++++---------- .../configSettings.unit.test.ts | 4 ++-- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 1949a3912ef6..1ce4dc6b69c4 100644 --- a/package.json +++ b/package.json @@ -652,9 +652,9 @@ "scope": "machine-overridable", "type": "string" }, - "python.pixiPath": { + "python.pixiToolPath": { "default": "pixi", - "description": "%python.pixiPath.description%", + "description": "%python.pixiToolPath.description%", "scope": "machine-overridable", "type": "string" }, diff --git a/package.nls.json b/package.nls.json index 13157ac29917..3e49ccaa4081 100644 --- a/package.nls.json +++ b/package.nls.json @@ -59,7 +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.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.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`.", diff --git a/resources/report_issue_user_settings.json b/resources/report_issue_user_settings.json index 5dd91923e102..ef85267c0e65 100644 --- a/resources/report_issue_user_settings.json +++ b/resources/report_issue_user_settings.json @@ -11,7 +11,7 @@ "condaPath": "placeholder", "pipenvPath": "placeholder", "poetryPath": "placeholder", - "pixiPath": "placeholder", + "pixiToolPath": "placeholder", "devOptions": false, "globalModuleInstallation": false, "languageServer": true, diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 7f06626050f7..dbd78c5287e5 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -103,7 +103,7 @@ export class PythonSettings implements IPythonSettings { public poetryPath = ''; - public pixiPath = ''; + public pixiToolPath = ''; public devOptions: string[] = []; @@ -262,8 +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 pixiPath = systemVariables.resolveAny(pythonSettings.get('pixiPath'))!; - this.pixiPath = pixiPath && pixiPath.length > 0 ? getAbsolutePath(pixiPath, workspaceRoot) : pixiPath; + 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/types.ts b/src/client/common/types.ts index ab76d8a1df61..c6c3a1e4a585 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -167,7 +167,7 @@ export interface IPythonSettings { readonly condaPath: string; readonly pipenvPath: string; readonly poetryPath: string; - readonly pixiPath: string; + readonly pixiToolPath: string; readonly devOptions: string[]; readonly testing: ITestingSettings; readonly autoComplete: IAutoCompleteSettings; diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index 2315f02fa876..200d198222e7 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -109,10 +109,10 @@ export class Pixi { function* getCandidates() { // Read the pixi location from the settings. try { - const customPixiPath = getPythonSetting('pixiPath'); - if (customPixiPath && customPixiPath !== 'pixi') { + const custompixiToolPath = getPythonSetting('pixiToolPath'); + if (custompixiToolPath && custompixiToolPath !== 'pixi') { // If user has specified a custom pixi path, use it first. - yield customPixiPath; + yield custompixiToolPath; } } catch (ex) { traceError(`Failed to get pixi setting`, ex); @@ -124,23 +124,23 @@ export class Pixi { // Check the default installation location const home = getUserHomeDir(); if (home) { - const defaultPixiPath = path.join(home, '.pixi', 'bin', 'pixi'); - if (pathExistsSync(defaultPixiPath)) { - yield defaultPixiPath; + 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 pixiPath of getCandidates()) { - traceVerbose(`Probing pixi binary for ${cwd}: ${pixiPath}`); - const pixi = new Pixi(pixiPath, cwd); + for (const pixiToolPath of getCandidates()) { + traceVerbose(`Probing pixi binary for ${cwd}: ${pixiToolPath}`); + const pixi = new Pixi(pixiToolPath, cwd); const virtualenvs = await pixi.getEnvList(); if (virtualenvs !== undefined) { - traceVerbose(`Found pixi via filesystem probing for ${cwd}: ${pixiPath}`); + traceVerbose(`Found pixi via filesystem probing for ${cwd}: ${pixiToolPath}`); return pixi; } - traceVerbose(`Failed to find pixi for ${cwd}: ${pixiPath}`); + traceVerbose(`Failed to find pixi for ${cwd}: ${pixiToolPath}`); } // Didn't find anything. diff --git a/src/test/common/configSettings/configSettings.unit.test.ts b/src/test/common/configSettings/configSettings.unit.test.ts index 35865d9e0395..c4389629e0ec 100644 --- a/src/test/common/configSettings/configSettings.unit.test.ts +++ b/src/test/common/configSettings/configSettings.unit.test.ts @@ -83,7 +83,7 @@ suite('Python Settings', async () => { 'pipenvPath', 'envFile', 'poetryPath', - 'pixiPath', + 'pixiToolPath', 'defaultInterpreterPath', ]) { config @@ -142,7 +142,7 @@ suite('Python Settings', async () => { 'pipenvPath', 'envFile', 'poetryPath', - 'pixiPath', + 'pixiToolPath', 'defaultInterpreterPath', ].forEach(async (settingName) => { testIfValueIsUpdated(settingName, 'stringValue'); From b066577866fb41b54afc695cde1c814f44e9097e Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 29 Feb 2024 14:55:41 +0100 Subject: [PATCH 05/10] test: adds test for the pixi locator --- .../lowLevel/pixiLocator.unit.test.ts | 92 ++++++++++++ .../environmentManagers/pixi.unit.test.ts | 131 ++++++++++++++++++ .../multi-env/.pixi/envs/py310/bin/python | 1 + .../multi-env/.pixi/envs/py311/bin/python | 1 + .../pixi/multi-env/.pixi/envs/py311/python | 0 .../envlayouts/pixi/multi-env/pixi.toml | 14 ++ .../non-windows/.pixi/envs/default/bin/python | 0 .../envlayouts/pixi/non-windows/pixi.toml | 11 ++ .../windows/.pixi/envs/default/python.exe | 1 + .../common/envlayouts/pixi/windows/pixi.toml | 12 ++ 10 files changed, 263 insertions(+) create mode 100644 src/test/pythonEnvironments/base/locators/lowLevel/pixiLocator.unit.test.ts create mode 100644 src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/bin/python create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/bin/python create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/python create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/pixi.toml create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/bin/python create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/pixi.toml create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/windows/.pixi/envs/default/python.exe create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/windows/pixi.toml 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..c086c57f8efb --- /dev/null +++ b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts @@ -0,0 +1,131 @@ +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 HatchCommand = { cmd: 'info --json' } | { 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[]): HatchCommand { + 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'); + } + + /// 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 + const cmd = pixiCommand(args); + 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, cwd: projectDirs.nonWindows.path } : undefined)); + const pixi = await Pixi.getPixi(projectDirs.nonWindows.path); + expect(pixi?.command).to.equal(pixiPath); + }; + + test('Return a Pixi instance in an empty directory', () => testPath('hatchPath', 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 hatch = await Pixi.getPixi(projectDirs.nonWindows.path); + expect(hatch?.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/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/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.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" From 66242f4e49170fa6b38bbc89d8e1384d813e8c95 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Tue, 26 Mar 2024 16:45:43 +0100 Subject: [PATCH 06/10] feat: activation & installation --- src/client/common/installer/pixiInstaller.ts | 80 +++++++++++++ .../common/installer/productInstaller.ts | 2 +- .../common/installer/serviceRegistry.ts | 2 + .../common/process/pythonEnvironment.ts | 18 +++ .../common/process/pythonExecutionFactory.ts | 25 +++- src/client/common/serviceRegistry.ts | 6 + .../pixiActivationProvider.ts | 110 ++++++++++++++++++ src/client/common/terminal/helper.ts | 14 ++- src/client/common/terminal/types.ts | 1 + .../commands/setInterpreter.ts | 1 + .../base/locators/lowLevel/pixiLocator.ts | 23 +++- .../common/environmentManagers/pixi.ts | 93 ++++++++++++++- src/client/pythonEnvironments/info/index.ts | 7 +- src/client/pythonEnvironments/legacyIOC.ts | 1 + .../terminals/activation.conda.unit.test.ts | 2 + src/test/common/terminals/helper.unit.test.ts | 4 + 16 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 src/client/common/installer/pixiInstaller.ts create mode 100644 src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts diff --git a/src/client/common/installer/pixiInstaller.ts b/src/client/common/installer/pixiInstaller.ts new file mode 100644 index 000000000000..411a9503a23e --- /dev/null +++ b/src/client/common/installer/pixiInstaller.ts @@ -0,0 +1,80 @@ +/* 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 conda environment. + */ +@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]; + if (pixiEnv?.manifestPath) { + args = args.concat(['--manifest-path', pixiEnv?.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..18c6f163afdb 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 { @@ -116,6 +117,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 +145,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/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/locators/lowLevel/pixiLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts index b3aafb0028ce..58544f9a9f4c 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts @@ -1,6 +1,7 @@ // 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'; @@ -9,7 +10,7 @@ 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'; +import { FSWatcherKind, FSWatchingLocator } from './fsWatchingLocator'; /** * Returns all virtual environment locations to look for in a workspace. @@ -20,11 +21,27 @@ async function getVirtualEnvDirs(root: string): Promise { return asyncFilter(envDirs, pathExists); } -export class PixiLocator extends LazyResourceBasedLocator { +/** + * 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(); + 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 { diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index 200d198222e7..51b3559b8056 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -5,10 +5,11 @@ import * as path from 'path'; import { OSType, getOSType, getUserHomeDir } from '../../../common/utils/platform'; -import { exec, getPythonSetting, pathExists, pathExistsSync } from '../externalDependencies'; +import { exec, getPythonSetting, isParentPath, pathExists, pathExistsSync } from '../externalDependencies'; import { cache } from '../../../common/utils/decorators'; import { isTestExecution } from '../../../common/constants'; import { traceError, traceVerbose } from '../../../logging'; +import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; // 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. @@ -40,6 +41,19 @@ export type PixiInfo = { }; export async function isPixiEnvironment(interpreterPath: string): Promise { + const projectDir = getPixiProjectFolderFromInterpreter(interpreterPath); + const pixiTomlPath = path.join(projectDir, 'pixi.toml'); + return pathExists(pixiTomlPath); +} + +/** + * Returns the path to the project directory based on the interpreter path. + * + * This function does not check if the path actually exists. + * + * @param interpreterPath The path to the interpreter + */ +export function getPixiProjectFolderFromInterpreter(interpreterPath: string): string { // We want to verify the following layout // project // |__ pixi.toml <-- check if this exists @@ -53,8 +67,8 @@ export async function isPixiEnvironment(interpreterPath: string): Promise { const infoOutput = await exec(this.command, ['info', '--json'], { cwd: this.cwd, - throwOnStdErr: true, - }).catch(traceVerbose); + throwOnStdErr: false, + }).catch(traceError); if (!infoOutput) { return undefined; } @@ -178,4 +192,73 @@ export class Pixi { const pixiInfo: PixiInfo = JSON.parse(infoOutput.stdout); return pixiInfo.environments_info.map((env) => env.prefix); } + + 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]; + } +} + +/** + * Returns true if interpreter path belongs to a pixi environment which is associated with a particular folder, + * false otherwise. + * + * @param interpreterPath Absolute path to any python interpreter. + * @param folder Absolute path to the folder. + * @param poetryPath Poetry command to use to calculate the result. + */ +export async function isPixiEnvironmentRelatedToFolder(interpreterPath: string, folder: string): Promise { + const projectPath = getPixiProjectFolderFromInterpreter(interpreterPath); + return isParentPath(folder, projectPath); +} + +export type PixiEnvironmentInfo = { + interpreterPath: string; + pixi: Pixi; + manifestPath: string; + projectDir: string; + envName?: string; +}; + +export async function getPixiEnvironmentFromInterpreter( + interpreterPath: string, +): Promise { + const envDir = getCondaEnvironmentFromInterpreterPath(interpreterPath); + const envsDir = path.dirname(envDir); + const envName = path.basename(envDir); + const pixiDir = path.dirname(envsDir); + const projectDir = path.dirname(pixiDir); + const manifestPath = path.join(projectDir, 'pixi.toml'); + + if (!(await pathExists(manifestPath))) { + return undefined; + } + + const pixi = await Pixi.getPixi(projectDir); + if (!pixi) { + return undefined; + } + + return { + interpreterPath, + manifestPath, + projectDir, + envName, + pixi, + }; +} + +/** + * 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/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/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)], ); } From d67ecc4c6d05003163382331adc7108db419f372 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 28 Mar 2024 12:00:53 +0100 Subject: [PATCH 07/10] fix: use manifest path from pixi info --- src/client/common/installer/pixiInstaller.ts | 6 ++- .../common/process/pythonEnvironment.ts | 2 +- .../common/process/pythonExecutionFactory.ts | 5 ++ .../pixiActivationProvider.ts | 2 +- .../common/environmentManagers/pixi.ts | 48 ++++++++++++------- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/client/common/installer/pixiInstaller.ts b/src/client/common/installer/pixiInstaller.ts index 411a9503a23e..1504fd7b52af 100644 --- a/src/client/common/installer/pixiInstaller.ts +++ b/src/client/common/installer/pixiInstaller.ts @@ -68,8 +68,10 @@ export class PixiInstaller extends ModuleInstaller { const execPath = pixiEnv?.pixi.command; let args = ['add', moduleName]; - if (pixiEnv?.manifestPath) { - args = args.concat(['--manifest-path', pixiEnv?.manifestPath]); + // eslint-disable-next-line camelcase + const manifestPath = pixiEnv?.projectInfo.manifest_path; + if (manifestPath !== undefined) { + args = args.concat(['--manifest-path', manifestPath]); } return { diff --git a/src/client/common/process/pythonEnvironment.ts b/src/client/common/process/pythonEnvironment.ts index cbf898ac5f50..98c38c2fa729 100644 --- a/src/client/common/process/pythonEnvironment.ts +++ b/src/client/common/process/pythonEnvironment.ts @@ -180,7 +180,7 @@ export async function createPixiEnv( procs: IProcessService, fs: IFileSystem, ): Promise { - const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.manifestPath, pixiEnv.envName); + const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.projectInfo.manifest_path, pixiEnv.envName); const deps = createDeps( async (filename) => fs.pathExists(filename), pythonArgv, diff --git a/src/client/common/process/pythonExecutionFactory.ts b/src/client/common/process/pythonExecutionFactory.ts index 18c6f163afdb..939c91514952 100644 --- a/src/client/common/process/pythonExecutionFactory.ts +++ b/src/client/common/process/pythonExecutionFactory.ts @@ -80,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; diff --git a/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts index f9110f6be60c..62d192c03b64 100644 --- a/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts +++ b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts @@ -46,7 +46,7 @@ export class PixiActivationCommandProvider implements ITerminalActivationCommand return undefined; } - const command = ['shell-hook', '--manifest-path', pixiEnv.manifestPath]; + const command = ['shell-hook', '--manifest-path', pixiEnv.projectInfo.manifest_path]; if (isNonDefaultPixiEnvironmentName(pixiEnv.envName)) { command.push('--environment'); command.push(pixiEnv.envName); diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index 51b3559b8056..de90e0d9bc80 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -8,7 +8,7 @@ import { OSType, getOSType, getUserHomeDir } from '../../../common/utils/platfor import { exec, getPythonSetting, isParentPath, pathExists, pathExistsSync } from '../externalDependencies'; import { cache } from '../../../common/utils/decorators'; import { isTestExecution } from '../../../common/constants'; -import { traceError, traceVerbose } from '../../../logging'; +import { traceError, traceVerbose, traceWarn } from '../../../logging'; import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; // This type corresponds to the output of 'pixi info --json', and property @@ -21,12 +21,7 @@ export type PixiInfo = { 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; - }; + project_info?: PixiProjectInfo /* eslint-disable-line camelcase */; environments_info: /* eslint-disable-line camelcase */ { name: string; @@ -40,6 +35,13 @@ export type PixiInfo = { }[]; }; +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 async function isPixiEnvironment(interpreterPath: string): Promise { const projectDir = getPixiProjectFolderFromInterpreter(interpreterPath); const pixiTomlPath = path.join(projectDir, 'pixi.toml'); @@ -180,7 +182,14 @@ export class Pixi { * so pass in cwd on which we need to cache. */ @cache(30_000, true, 10_000) - private async getEnvListCached(_cwd: string): Promise { + private async getEnvListCached(cwd: string): Promise { + const pixiInfo = await this.getPixiInfo(cwd); + // eslint-disable-next-line camelcase + return pixiInfo?.environments_info.map((env) => env.prefix); + } + + @cache(1_000, true, 1_000) + public async getPixiInfo(_cwd: string): Promise { const infoOutput = await exec(this.command, ['info', '--json'], { cwd: this.cwd, throwOnStdErr: false, @@ -190,7 +199,7 @@ export class Pixi { } const pixiInfo: PixiInfo = JSON.parse(infoOutput.stdout); - return pixiInfo.environments_info.map((env) => env.prefix); + return pixiInfo; } public getRunPythonArgs(manifestPath: string, envName?: string, isolatedFlag = false): string[] { @@ -213,7 +222,6 @@ export class Pixi { * * @param interpreterPath Absolute path to any python interpreter. * @param folder Absolute path to the folder. - * @param poetryPath Poetry command to use to calculate the result. */ export async function isPixiEnvironmentRelatedToFolder(interpreterPath: string, folder: string): Promise { const projectPath = getPixiProjectFolderFromInterpreter(interpreterPath); @@ -223,7 +231,8 @@ export async function isPixiEnvironmentRelatedToFolder(interpreterPath: string, export type PixiEnvironmentInfo = { interpreterPath: string; pixi: Pixi; - manifestPath: string; + pixiVersion: string; + projectInfo: PixiProjectInfo; projectDir: string; envName?: string; }; @@ -236,20 +245,25 @@ export async function getPixiEnvironmentFromInterpreter( const envName = path.basename(envDir); const pixiDir = path.dirname(envsDir); const projectDir = path.dirname(pixiDir); - const manifestPath = path.join(projectDir, 'pixi.toml'); - if (!(await pathExists(manifestPath))) { + // Find the pixi executable for the project + const pixi = await Pixi.getPixi(projectDir); + if (!pixi) { + traceWarn(`could not find a pixi interpreter for the interpreter at ${interpreterPath}`); return undefined; } - const pixi = await Pixi.getPixi(projectDir); - if (!pixi) { + // Invoke pixi to get information about the pixi project + const pixiInfo = await pixi.getPixiInfo(projectDir); + if (!pixiInfo || !pixiInfo.project_info) { + traceWarn(`failed to determine pixi project information for the interpreter at ${interpreterPath}`); return undefined; } return { interpreterPath, - manifestPath, + pixiVersion: pixiInfo.version, + projectInfo: pixiInfo.project_info, projectDir, envName, pixi, @@ -257,7 +271,7 @@ export async function getPixiEnvironmentFromInterpreter( } /** - * Returns true if the given environment name is not the default environment. + * 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'; From 50234f21df94f7598090c9b52dde2c08c5460d0a Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 13 Jun 2024 10:06:38 +0200 Subject: [PATCH 08/10] fix: simplify pixi logic --- src/client/common/installer/pixiInstaller.ts | 2 +- .../common/process/pythonEnvironment.ts | 2 +- .../pixiActivationProvider.ts | 2 +- .../base/locators/lowLevel/pixiLocator.ts | 4 +- .../common/environmentManagers/pixi.ts | 204 ++++++++++-------- .../environmentManagers/pixi.unit.test.ts | 6 +- 6 files changed, 123 insertions(+), 97 deletions(-) diff --git a/src/client/common/installer/pixiInstaller.ts b/src/client/common/installer/pixiInstaller.ts index 1504fd7b52af..2d68c74248d8 100644 --- a/src/client/common/installer/pixiInstaller.ts +++ b/src/client/common/installer/pixiInstaller.ts @@ -69,7 +69,7 @@ export class PixiInstaller extends ModuleInstaller { let args = ['add', moduleName]; // eslint-disable-next-line camelcase - const manifestPath = pixiEnv?.projectInfo.manifest_path; + const manifestPath = pixiEnv?.manifestPath; if (manifestPath !== undefined) { args = args.concat(['--manifest-path', manifestPath]); } diff --git a/src/client/common/process/pythonEnvironment.ts b/src/client/common/process/pythonEnvironment.ts index 98c38c2fa729..cbf898ac5f50 100644 --- a/src/client/common/process/pythonEnvironment.ts +++ b/src/client/common/process/pythonEnvironment.ts @@ -180,7 +180,7 @@ export async function createPixiEnv( procs: IProcessService, fs: IFileSystem, ): Promise { - const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.projectInfo.manifest_path, pixiEnv.envName); + const pythonArgv = pixiEnv.pixi.getRunPythonArgs(pixiEnv.manifestPath, pixiEnv.envName); const deps = createDeps( async (filename) => fs.pathExists(filename), pythonArgv, diff --git a/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts index 62d192c03b64..f9110f6be60c 100644 --- a/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts +++ b/src/client/common/terminal/environmentActivationProviders/pixiActivationProvider.ts @@ -46,7 +46,7 @@ export class PixiActivationCommandProvider implements ITerminalActivationCommand return undefined; } - const command = ['shell-hook', '--manifest-path', pixiEnv.projectInfo.manifest_path]; + const command = ['shell-hook', '--manifest-path', pixiEnv.manifestPath]; if (isNonDefaultPixiEnvironmentName(pixiEnv.envName)) { command.push('--environment'); command.push(pixiEnv.envName); diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts index 58544f9a9f4c..7cdc78ec6f10 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/pixiLocator.ts @@ -16,8 +16,8 @@ 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(root); - const envDirs = (await pixi?.getEnvList()) ?? []; + const pixi = await Pixi.getPixi(); + const envDirs = (await pixi?.getEnvList(root)) ?? []; return asyncFilter(envDirs, pathExists); } diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index de90e0d9bc80..a7ef3e6663c0 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -4,13 +4,16 @@ 'use strict'; import * as path from 'path'; +import { readJSON } from 'fs-extra'; import { OSType, getOSType, getUserHomeDir } from '../../../common/utils/platform'; -import { exec, getPythonSetting, isParentPath, pathExists, pathExistsSync } from '../externalDependencies'; +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 = { @@ -42,41 +45,23 @@ export type PixiProjectInfo = { version: string; }; -export async function isPixiEnvironment(interpreterPath: string): Promise { - const projectDir = getPixiProjectFolderFromInterpreter(interpreterPath); - const pixiTomlPath = path.join(projectDir, 'pixi.toml'); - return pathExists(pixiTomlPath); -} +export type PixiEnvMetadata = { + manifest_path: string; // eslint-disable-line camelcase + pixi_version: string; // eslint-disable-line camelcase + environment_name: string; // eslint-disable-line camelcase +}; -/** - * Returns the path to the project directory based on the interpreter path. - * - * This function does not check if the path actually exists. - * - * @param interpreterPath The path to the interpreter - */ -export function getPixiProjectFolderFromInterpreter(interpreterPath: string): string { - // We want to verify the following layout - // project - // |__ pixi.toml <-- check if this exists - // |__ .pixi - // |__ envs - // |__ - // |__ bin/"" - // |__ python <-- interpreterPath - - const envDir = getCondaEnvironmentFromInterpreterPath(interpreterPath); - const envsDir = path.dirname(envDir); - const pixiDir = path.dirname(envsDir); - const projectDir = path.dirname(pixiDir); - - return projectDir; +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 getCondaEnvironmentFromInterpreterPath(interpreterPath: string): string { +export function getPrefixFromInterpreterPath(interpreterPath: string): string { const interpreterDir = path.dirname(interpreterPath); if (getOSType() === OSType.Windows) { return interpreterDir; @@ -91,16 +76,19 @@ 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> = new Map>(); + 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. - * @param cwd - The working directory to use as cwd when running pixi. */ - constructor(public readonly command: string, private cwd: string) {} + 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. @@ -109,26 +97,26 @@ export class Pixi { * 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 { - if (Pixi.pixiPromise.get(cwd) === undefined || isTestExecution()) { - Pixi.pixiPromise.set(cwd, Pixi.locate(cwd)); + public static async getPixi(): Promise { + if (Pixi.pixiPromise === undefined || isTestExecution()) { + Pixi.pixiPromise = Pixi.locate(); } - return Pixi.pixiPromise.get(cwd); + return Pixi.pixiPromise; } - private static async locate(cwd: string): Promise { + 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 for cwd ${cwd}`); + 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'); - if (custompixiToolPath && custompixiToolPath !== 'pixi') { + const customPixiToolPath = getPythonSetting(PIXITOOLPATH_SETTING_KEY); + if (customPixiToolPath && customPixiToolPath !== 'pixi') { // If user has specified a custom pixi path, use it first. - yield custompixiToolPath; + yield customPixiToolPath; } } catch (ex) { traceError(`Failed to get pixi setting`, ex); @@ -149,49 +137,40 @@ export class Pixi { // Probe the candidates, and pick the first one that exists and does what we need. for (const pixiToolPath of getCandidates()) { - traceVerbose(`Probing pixi binary for ${cwd}: ${pixiToolPath}`); - const pixi = new Pixi(pixiToolPath, cwd); - const virtualenvs = await pixi.getEnvList(); - if (virtualenvs !== undefined) { - traceVerbose(`Found pixi via filesystem probing for ${cwd}: ${pixiToolPath}`); + 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 for ${cwd}: ${pixiToolPath}`); + traceVerbose(`Failed to find pixi: ${pixiToolPath}`); } // Didn't find anything. - traceVerbose(`No pixi binary found for ${cwd}`); + traceVerbose(`No pixi binary found`); 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). + * 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(): Promise { - 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 { + 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 { + public async getPixiInfo(cwd: string): Promise { const infoOutput = await exec(this.command, ['info', '--json'], { - cwd: this.cwd, + cwd, throwOnStdErr: false, }).catch(traceError); if (!infoOutput) { @@ -202,6 +181,28 @@ export class Pixi { 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)) { @@ -214,47 +215,73 @@ export class Pixi { } return [...python, OUTPUT_MARKER_SCRIPT]; } -} -/** - * Returns true if interpreter path belongs to a pixi environment which is associated with a particular folder, - * false otherwise. - * - * @param interpreterPath Absolute path to any python interpreter. - * @param folder Absolute path to the folder. - */ -export async function isPixiEnvironmentRelatedToFolder(interpreterPath: string, folder: string): Promise { - const projectPath = getPixiProjectFolderFromInterpreter(interpreterPath); - return isParentPath(folder, projectPath); + /** + * 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; - projectInfo: PixiProjectInfo; - projectDir: 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 { - const envDir = getCondaEnvironmentFromInterpreterPath(interpreterPath); - const envsDir = path.dirname(envDir); - const envName = path.basename(envDir); - const pixiDir = path.dirname(envsDir); - const projectDir = path.dirname(pixiDir); + const prefix = getPrefixFromInterpreterPath(interpreterPath); // Find the pixi executable for the project - const pixi = await Pixi.getPixi(projectDir); + 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(projectDir); + 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; @@ -262,11 +289,10 @@ export async function getPixiEnvironmentFromInterpreter( return { interpreterPath, + pixi, pixiVersion: pixiInfo.version, - projectInfo: pixiInfo.project_info, - projectDir, + manifestPath: pixiInfo.project_info.manifest_path, envName, - pixi, }; } diff --git a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts index c086c57f8efb..1aca7d4de33c 100644 --- a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts @@ -111,7 +111,7 @@ suite('Pixi binary is located correctly', async () => { getPythonSetting.returns(pixiPath); // If `verify` is false, don’t verify that the command has been called with that path exec.callsFake(makeExecHandler(verify ? { pixiPath, cwd: projectDirs.nonWindows.path } : undefined)); - const pixi = await Pixi.getPixi(projectDirs.nonWindows.path); + const pixi = await Pixi.getPixi(); expect(pixi?.command).to.equal(pixiPath); }; @@ -125,7 +125,7 @@ suite('Pixi binary is located correctly', async () => { exec.callsFake((_file: string, _args: string[], _options: ShellOptions) => Promise.reject(new Error('Command failed')), ); - const hatch = await Pixi.getPixi(projectDirs.nonWindows.path); - expect(hatch?.command).to.equal(undefined); + const pixi = await Pixi.getPixi(); + expect(pixi?.command).to.equal(undefined); }); }); From 8a2153ddacb256822d71c289560d58f6af5989aa Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 13 Jun 2024 13:06:26 +0200 Subject: [PATCH 09/10] fix: all unittests --- .../common/environmentManagers/pixi.ts | 4 ++++ src/test/.vscode/settings.json | 3 ++- .../environmentManagers/pixi.unit.test.ts | 18 +++++++++++++----- .../multi-env/.pixi/envs/py310/conda-meta/pixi | 0 .../multi-env/.pixi/envs/py311/conda-meta/pixi | 0 .../.pixi/envs/default/conda-meta/pixi | 0 6 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py310/conda-meta/pixi create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/multi-env/.pixi/envs/py311/conda-meta/pixi create mode 100644 src/test/pythonEnvironments/common/envlayouts/pixi/non-windows/.pixi/envs/default/conda-meta/pixi diff --git a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts index a7ef3e6663c0..f3d6dc3e081e 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/pixi.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/pixi.ts @@ -250,6 +250,10 @@ export async function getPixiEnvironmentFromInterpreter( interpreterPath: string, pixi?: Pixi, ): Promise { + if (!interpreterPath) { + return undefined; + } + const prefix = getPrefixFromInterpreterPath(interpreterPath); // Find the pixi executable for the project diff --git a/src/test/.vscode/settings.json b/src/test/.vscode/settings.json index faeb48ffa29c..cd2b4152591d 100644 --- a/src/test/.vscode/settings.json +++ b/src/test/.vscode/settings.json @@ -13,5 +13,6 @@ "python.linting.banditEnabled": false, // Don't set this to `Pylance`, for CI we want to use the LS that ships with the extension. "python.languageServer": "Jedi", - "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe" + "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe", + "python.defaultInterpreterPath": "python" } diff --git a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts index 1aca7d4de33c..d6b283c69fd3 100644 --- a/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentManagers/pixi.unit.test.ts @@ -6,7 +6,7 @@ import * as externalDependencies from '../../../../client/pythonEnvironments/com import { TEST_LAYOUT_ROOT } from '../commonTestConstants'; import { Pixi } from '../../../../client/pythonEnvironments/common/environmentManagers/pixi'; -export type HatchCommand = { cmd: 'info --json' } | { cmd: null }; +export type PixiCommand = { cmd: 'info --json' } | { cmd: '--version' } | { cmd: null }; const textPixiDir = path.join(TEST_LAYOUT_ROOT, 'pixi'); export const projectDirs = { @@ -51,7 +51,11 @@ export const projectDirs = { /** * Convert the command line arguments into a typed command. */ -export function pixiCommand(args: string[]): HatchCommand { +export function pixiCommand(args: string[]): PixiCommand { + if (args[0] === '--version') { + return { cmd: '--version' }; + } + if (args.length < 2) { return { cmd: null }; } @@ -72,6 +76,11 @@ export function makeExecHandler(verify: VerifyOptions = {}) { 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) { @@ -81,7 +90,6 @@ export function makeExecHandler(verify: VerifyOptions = {}) { } /// Convert the command into a single string - const cmd = pixiCommand(args); if (cmd.cmd === 'info --json') { const project = Object.values(projectDirs).find((p) => cwd?.startsWith(p.path)); if (!project) { @@ -110,12 +118,12 @@ suite('Pixi binary is located correctly', async () => { 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, cwd: projectDirs.nonWindows.path } : undefined)); + 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('hatchPath', false)); + 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')); 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/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/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 From cdee0a4e9d8973734cc3dc050897e590ad910000 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra Date: Thu, 13 Jun 2024 15:23:34 +0200 Subject: [PATCH 10/10] fix: small issues --- src/client/common/installer/pixiInstaller.ts | 3 +-- src/test/.vscode/settings.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/client/common/installer/pixiInstaller.ts b/src/client/common/installer/pixiInstaller.ts index 2d68c74248d8..8a2278830b51 100644 --- a/src/client/common/installer/pixiInstaller.ts +++ b/src/client/common/installer/pixiInstaller.ts @@ -14,7 +14,7 @@ import { InterpreterUri } from './types'; import { getPixiEnvironmentFromInterpreter } from '../../pythonEnvironments/common/environmentManagers/pixi'; /** - * A Python module installer for a conda environment. + * A Python module installer for a pixi project. */ @injectable() export class PixiInstaller extends ModuleInstaller { @@ -68,7 +68,6 @@ export class PixiInstaller extends ModuleInstaller { const execPath = pixiEnv?.pixi.command; let args = ['add', moduleName]; - // eslint-disable-next-line camelcase const manifestPath = pixiEnv?.manifestPath; if (manifestPath !== undefined) { args = args.concat(['--manifest-path', manifestPath]); diff --git a/src/test/.vscode/settings.json b/src/test/.vscode/settings.json index cd2b4152591d..faeb48ffa29c 100644 --- a/src/test/.vscode/settings.json +++ b/src/test/.vscode/settings.json @@ -13,6 +13,5 @@ "python.linting.banditEnabled": false, // Don't set this to `Pylance`, for CI we want to use the LS that ships with the extension. "python.languageServer": "Jedi", - "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe", - "python.defaultInterpreterPath": "python" + "python.pythonPath": "C:\\GIT\\s p\\vscode-python\\.venv\\Scripts\\python.exe" }