diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a9de4..d2f31c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the "pixi-code" extension will be documented in this file The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] + +- Keep valid Python environments visible when a Pixi environment targets an unsupported platform +- Handle package refresh failures and use Pixi environment names for package queries +- Support relative paths and `${workspaceFolder}` in `pixi-code.pixiExecutable` +- Use the first workspace folder as the default working directory for Pixi commands + ## [0.2.0] - Use published @vscode/python-environments API package diff --git a/README.md b/README.md index 21cd0c8..9007a02 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The extension will automatically discover Pixi environments and register them wi ## Extension Settings -- `pixi-code.pixiExecutable`: Path to the Pixi executable. Leave empty to use auto-discovery (default). +- `pixi-code.pixiExecutable`: Path to the Pixi executable. Supports `${workspaceFolder}` and paths relative to a workspace folder. Leave empty to use auto-discovery (default). ## Limitations diff --git a/package-lock.json b/package-lock.json index 6cf5cc1..88e4c77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixi-code", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixi-code", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@types/semver": "^7.7.1", diff --git a/package.json b/package.json index 969d98f..532ec7b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Pixi support for the Python Environments extension", "icon": "assets/icon.png", "license": "MIT", - "version": "0.2.0", + "version": "0.2.1", "homepage": "https://github.com/kaiserls/pixi-code", "repository": { "type": "git", @@ -51,7 +51,7 @@ "pixi-code.pixiExecutable": { "type": "string", "default": "", - "description": "Path to the Pixi executable. Leave empty to use auto-discovery.", + "description": "Path to the Pixi executable. Supports ${workspaceFolder} and paths relative to a workspace folder. Leave empty to use auto-discovery.", "scope": "machine-overridable" } } diff --git a/src/pixi/projectManager.ts b/src/pixi/projectManager.ts index b76b4a1..36e5bbe 100644 --- a/src/pixi/projectManager.ts +++ b/src/pixi/projectManager.ts @@ -20,7 +20,7 @@ import { window, } from 'vscode'; -import { traceVerbose } from '../common/logging'; +import { traceInfo, traceVerbose } from '../common/logging'; import { PixiEnvironment } from './types'; import { listPixiPackages, pixiPkgsToPackages } from './utils'; @@ -72,13 +72,27 @@ export class PixiPackageManager implements PackageManager, Disposable { } const projectPath = path.dirname(environment.pixiInfo.project_info.manifest_path); - const pixiPackages = await listPixiPackages(environment.name, projectPath); + const pixiEnvironment = environment.pixiInfo.environments_info.find( + (info) => info.prefix === environment.envId.id, + ); + if (!pixiEnvironment) { + traceInfo( + `Failed to refresh packages: Pixi environment '${environment.name}' is missing from project info`, + ); + return; + } - const before = environment.packages; - const after = pixiPkgsToPackages(pixiPackages, environment.envId.id); + try { + const pixiPackages = await listPixiPackages(pixiEnvironment.name, projectPath); - environment.packages = after; - this.triggerOnDidChangePackages(environment, before, after); + const before = environment.packages; + const after = pixiPkgsToPackages(pixiPackages, environment.envId.id); + + environment.packages = after; + this.triggerOnDidChangePackages(environment, before, after); + } catch (error) { + traceInfo(`Failed to refresh packages for environment '${environment.name}': ${error}`); + } }, ); } diff --git a/src/pixi/utils.ts b/src/pixi/utils.ts index f49d12e..04b0a86 100644 --- a/src/pixi/utils.ts +++ b/src/pixi/utils.ts @@ -1,10 +1,12 @@ import { Package } from '@vscode/python-environments'; import * as ch from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import { CancellationError, CancellationToken, ThemeIcon, Uri, window, workspace } from 'vscode'; import which from 'which'; import { createDeferred } from '../common/deferred'; -import { quoteArgs } from '../common/execUtils'; +import { quoteArgs, quoteStringIfNecessary } from '../common/execUtils'; import { findPythonExecutable } from '../common/findPython'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; import { getWorkspacePersistentState } from '../common/persistentState'; @@ -27,7 +29,21 @@ export async function getPixi(): Promise { const value = config.get('pixiExecutable'); if (value) { - return untildify(value); + let resolved = untildify(value); + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + const firstFolder = workspace.workspaceFolders[0].uri.fsPath; + resolved = path.normalize(resolved.replace(/\$\{workspaceFolder\}/g, firstFolder)); + if (!path.isAbsolute(resolved)) { + for (const folder of workspace.workspaceFolders) { + const candidate = path.resolve(folder.uri.fsPath, resolved); + if (fs.existsSync(candidate)) { + resolved = candidate; + break; + } + } + } + } + return resolved; } const pixiPath = await findPixi(); @@ -48,7 +64,7 @@ async function _runPixi( ): Promise { const deferred = createDeferred(); args = quoteArgs(args); - const proc = ch.spawn(pixi, args, { shell: true, ...options }); + const proc = ch.spawn(quoteStringIfNecessary(pixi), args, { shell: true, ...options }); const cancelDisposable = token?.onCancellationRequested(() => { proc.kill(); @@ -87,7 +103,12 @@ async function _runPixi( export async function runPixi(args: string[], options?: ch.SpawnOptions, token?: CancellationToken): Promise { const pixi = await getPixi(); - return _runPixi(pixi, args, options, token); + const defaultCwd = workspace.workspaceFolders?.[0]?.uri.fsPath; + const spawnOptions: ch.SpawnOptions = { + ...(defaultCwd ? { cwd: defaultCwd } : {}), + ...options, + }; + return _runPixi(pixi, args, spawnOptions, token); } export async function listPixiPackages(envName: string, projectPath: string): Promise { @@ -113,7 +134,14 @@ export async function refreshPixi(projectPath: string): Promise { - const pixiPackages = await listPixiPackages(pixiEnv.name, projectPath); + let pixiPackages: PixiPackage[]; + try { + pixiPackages = await listPixiPackages(pixiEnv.name, projectPath); + } catch (error) { + traceInfo(`Skipping Pixi environment '${pixiEnv.name}': ${error}`); + return null; + } + const pythonPackage = pixiPackages.find((pkg) => pkg.name === 'python'); if (!pythonPackage) { diff --git a/src/test/pixi.unit.test.ts b/src/test/pixi.unit.test.ts new file mode 100644 index 0000000..a04db0f --- /dev/null +++ b/src/test/pixi.unit.test.ts @@ -0,0 +1,228 @@ +import * as assert from 'node:assert/strict'; +import { spawn as realSpawn, SpawnOptions } from 'node:child_process'; +import { EventEmitter as NodeEventEmitter } from 'node:events'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; + +import { PixiEnvironment } from '../pixi/types'; + +type SpawnCall = { executable: string; args: string[]; options: SpawnOptions }; +type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number }; + +const calls: SpawnCall[] = []; +const infoMessages: string[] = []; +let configuredExecutable = 'pixi'; +let workspaceFolders: { uri: { fsPath: string } }[] = []; +let respond: (call: SpawnCall) => SpawnResult = () => ({ exitCode: 0 }); +let useRealSpawn = false; + +class TestEventEmitter { + event = () => undefined; + fire(_value: T) {} + dispose() {} +} + +const vscode = { + CancellationError: class extends Error {}, + EventEmitter: TestEventEmitter, + ProgressLocation: { Window: 1 }, + ThemeIcon: class { + constructor(readonly id: string) {} + }, + Uri: { file: (fsPath: string) => ({ fsPath }) }, + window: { + showErrorMessage: () => undefined, + withProgress: async (_options: unknown, task: () => Promise) => task(), + }, + workspace: { + getConfiguration: () => ({ get: () => configuredExecutable }), + get workspaceFolders() { + return workspaceFolders; + }, + }, +}; + +const childProcess = { + spawn(executable: string, args: string[], options: SpawnOptions) { + const call = { executable, args, options }; + calls.push(call); + if (useRealSpawn) { + return realSpawn(executable, args, options); + } + const proc = new NodeEventEmitter() as NodeEventEmitter & { + stdout: NodeEventEmitter; + stderr: NodeEventEmitter; + kill: () => void; + }; + proc.stdout = new NodeEventEmitter(); + proc.stderr = new NodeEventEmitter(); + proc.kill = () => undefined; + queueMicrotask(() => { + const result = respond(call); + if (result.stdout) { + proc.stdout.emit('data', result.stdout); + } + if (result.stderr) { + proc.stderr.emit('data', result.stderr); + } + proc.emit('exit', result.exitCode ?? 0); + proc.emit('close'); + }); + return proc; + }, +}; + +// VS Code is unavailable in Node's unit-test process. Load the production modules +// with only their host APIs replaced; the discovery and command logic remains real. +const loader = require('node:module') as { + _load: (request: string, parent?: NodeModule, isMain?: boolean) => unknown; +}; +const originalLoad = loader._load; +loader._load = function (request, parent, isMain) { + if (request === 'vscode') { + return vscode; + } + if (request === 'child_process') { + return childProcess; + } + if (request === '@vscode/python-environments') { + return { PackageChangeKind: { add: 1, remove: 2 } }; + } + return originalLoad.call(this, request, parent, isMain); +}; +const { getPixi, refreshPixi, runPixi } = require('../pixi/utils') as typeof import('../pixi/utils'); +const { PixiPackageManager } = require('../pixi/projectManager') as typeof import('../pixi/projectManager'); +const { registerLogger } = require('../common/logging') as typeof import('../common/logging'); +loader._load = originalLoad; + +registerLogger({ + info: (message: string) => void infoMessages.push(message), + error: () => undefined, + debug: () => undefined, +} as unknown as Parameters[0]); + +test('an unsupported Pixi environment does not hide valid Python environments', async () => { + calls.length = 0; + infoMessages.length = 0; + workspaceFolders = [{ uri: { fsPath: path.resolve('workspace') } }]; + const projectPath = path.resolve('workspace', 'project'); + respond = ({ args }) => { + if (args[0] === 'info') { + return { + stdout: JSON.stringify({ + project_info: { name: 'project', manifest_path: path.join(projectPath, 'pixi.toml') }, + environments_info: [ + { name: 'default', prefix: path.join(projectPath, '.pixi', 'envs', 'default') }, + { name: 'foreign', prefix: path.join(projectPath, '.pixi', 'envs', 'foreign') }, + { name: 'test', prefix: path.join(projectPath, '.pixi', 'envs', 'test') }, + ], + }), + }; + } + const envName = args.at(-1); + if (envName === 'foreign') { + return { stderr: "No packages found in 'foreign' environment for this platform", exitCode: 1 }; + } + return { stdout: JSON.stringify([{ name: 'python', version: '3.12.1', is_explicit: true }]) }; + }; + + const environments = await refreshPixi(projectPath); + + assert.deepEqual( + environments.map((environment) => environment.name), + ['project:default (3.12.1)', 'project:test (3.12.1)'], + ); + assert.equal(calls.length, 4); + assert.ok(calls.every((call) => call.options.cwd === projectPath)); + assert.ok(infoMessages.some((message) => message.includes("Skipping Pixi environment 'foreign'"))); +}); + +test('package refresh uses the Pixi environment name and keeps existing packages on failure', async () => { + calls.length = 0; + infoMessages.length = 0; + const projectPath = path.resolve('workspace', 'project'); + const prefix = path.join(projectPath, '.pixi', 'envs', 'default'); + const packages = [{ name: 'python', version: '3.12.1' }]; + const environment = { + name: 'project:default (3.12.1)', + envId: { id: prefix }, + packages, + pixiInfo: { + project_info: { name: 'project', manifest_path: path.join(projectPath, 'pixi.toml') }, + environments_info: [{ name: 'default', prefix }], + }, + } as unknown as PixiEnvironment; + const manager = new PixiPackageManager( + {} as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + + respond = () => ({ stderr: 'Pixi list failed', exitCode: 1 }); + await manager.refresh(environment); + assert.equal(environment.packages, packages); + assert.equal(calls[0].args.at(-1), 'default'); + assert.equal(calls[0].options.cwd, projectPath); + assert.ok(infoMessages.some((message) => message.includes('Failed to refresh packages'))); + + respond = () => ({ stdout: JSON.stringify([{ name: 'python', version: '3.13.0', is_explicit: true }]) }); + await manager.refresh(environment); + assert.equal(environment.packages[0].version, '3.13.0'); +}); + +test('configured Pixi paths resolve from workspaces and commands use a workspace cwd', async () => { + calls.length = 0; + const root = await mkdtemp(path.join(os.tmpdir(), 'pixi code ')); + const firstFolder = path.join(root, 'first'); + const secondFolder = path.join(root, 'second'); + const secondExecutable = path.join(secondFolder, 'bin', 'pixi'); + try { + await mkdir(path.dirname(secondExecutable), { recursive: true }); + await mkdir(firstFolder); + await writeFile(secondExecutable, ''); + workspaceFolders = [{ uri: { fsPath: firstFolder } }, { uri: { fsPath: secondFolder } }]; + configuredExecutable = path.join('bin', 'pixi'); + assert.equal(await getPixi(), secondExecutable); + + respond = () => ({ stdout: 'pixi 0.67.0' }); + assert.equal(await runPixi(['--version']), 'pixi 0.67.0'); + assert.equal(calls[0].executable, `"${secondExecutable}"`); + assert.equal(calls[0].options.cwd, firstFolder); + + await runPixi(['--version'], { cwd: secondFolder }); + assert.equal(calls[1].options.cwd, secondFolder); + + configuredExecutable = '${workspaceFolder}/bin/pixi'; + assert.equal(await getPixi(), path.join(firstFolder, 'bin', 'pixi')); + } finally { + configuredExecutable = 'pixi'; + workspaceFolders = []; + await rm(root, { recursive: true, force: true }); + } +}); + +test('a configured executable in a path with spaces can run through the shell', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'pixi executable ')); + const executable = path.join(root, process.platform === 'win32' ? 'pixi.cmd' : 'pixi'); + try { + const script = + process.platform === 'win32' + ? '@echo off\r\necho pixi-test %1\r\n' + : '#!/bin/sh\nprintf "pixi-test %s\\n" "$1"\n'; + await writeFile(executable, script); + if (process.platform !== 'win32') { + await chmod(executable, 0o755); + } + configuredExecutable = executable; + workspaceFolders = [{ uri: { fsPath: root } }]; + useRealSpawn = true; + + assert.equal((await runPixi(['--version'])).trim(), 'pixi-test --version'); + } finally { + useRealSpawn = false; + configuredExecutable = 'pixi'; + workspaceFolders = []; + await rm(root, { recursive: true, force: true }); + } +});