diff --git a/SETTINGS_ARCHITECTURE.md b/SETTINGS_ARCHITECTURE.md new file mode 100644 index 00000000..36534262 --- /dev/null +++ b/SETTINGS_ARCHITECTURE.md @@ -0,0 +1,300 @@ +# Class-Based Command Architecture with Three-Level Hierarchy + +## Overview + +Implemented package management commands using a three-level class hierarchy that separates concerns cleanly: + +1. **Base class** (`PackageManagerCommand`) — minimal shared interface +2. **Template classes** (`InstallCommand`, `ListCommand`, etc.) — load command-specific settings +3. **Concrete classes** (`PipInstallCommand`, `CondaInstallCommand`, etc.) — implement package-manager-specific logic + +This approach stores persisting arguments (like `indexUrl`) as instance properties while keeping ephemeral arguments (like packages) passed to `execute()`. + +## Architecture Components + +### 1. Base Class + +**File**: `src/managers/builtin/commands/commandSettings.ts` + +```typescript +interface CommandConstructorOptions { + pythonExecutable: string; + log?: LogOutputChannel; + cancellationToken?: CancellationToken; +} + +abstract class PackageManagerCommand { + protected pythonExecutable: string; + protected log?: LogOutputChannel; + protected cancellationToken?: CancellationToken; + + constructor(options: CommandConstructorOptions) { + this.pythonExecutable = options.pythonExecutable; + this.log = options.log; + this.cancellationToken = options.cancellationToken; + } + + protected abstract buildCommand(ephemeralArgs: unknown): string[]; +} +``` + +Minimal interface: only shared across all commands. + +### 2. Template Classes + +Each command type (install, uninstall, list, etc.) has a template class that: + +- Loads its own command-specific settings from VS Code config +- Defines the execute() interface (signature varies per command) +- Is abstract (not instantiable directly) + +#### InstallCommand Template + +```typescript +abstract class InstallCommand extends PackageManagerCommand { + protected settings: CommandSettings; + + constructor(options: CommandConstructorOptions) { + super(options); + const config = getConfiguration('python-envs.packageManager.installCommandArgs'); + this.settings = { + executionTimeout: config.get('executionTimeout', 300000), + verboseOutput: config.get('verboseOutput', false), + retryOnFailure: config.get('retryOnFailure', true), + maxRetries: config.get('maxRetries', 1), + }; + } + + abstract execute(packages: { packageName: string; version?: string }[], upgrade?: boolean): Promise; +} +``` + +### 3. Concrete Classes + +Each concrete class implements `buildCommand()` and `execute()` with package-manager-specific logic. + +#### PipInstallCommand (Concrete) + +```typescript +export class PipInstallCommand extends InstallCommand { + private indexUrl?: string; // Persisting argument + + constructor(options: CommandConstructorOptions) { + super(options); + const config = getConfiguration('python-envs.packageManager'); + this.indexUrl = config.get('indexUrl'); // Load global config + } + + // buildCommand uses persisting args (indexUrl) + ephemeral args (packages, upgrade) + protected buildCommand(ephemeralArgs: InstallEphemeralArgs): string[] { + let args = ['-m', 'pip', 'install']; + + if (this.indexUrl) { + args.push('--index-url', this.indexUrl); + } + + if (ephemeralArgs.upgrade) { + args.push('--upgrade'); + } + + const processedArgs = processEditableInstallArgs(ephemeralArgs.packages.map((pkg) => pkg.packageName)); + args.push(...processedArgs); + + return args; + } + + // execute() spawns subprocess directly with runPython + async execute(packages: { packageName: string; version?: string }[], upgrade?: boolean): Promise { + const args = this.buildCommand({ packages, upgrade }); + + await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + this.cancellationToken, + this.settings.executionTimeout, + ); + } +} +``` + +#### CondaInstallCommand (Concrete, Different Package Manager) + +```typescript +export class CondaInstallCommand extends InstallCommand { + protected buildCommand(ephemeralArgs: InstallEphemeralArgs): string[] { + let args = ['install', '-y']; + + if (ephemeralArgs.upgrade) { + args.push('--upgrade'); + } + + args.push(...ephemeralArgs.packages.map((p) => p.packageName)); + + return args; + } + + async execute(packages: { packageName: string; version?: string }[], upgrade?: boolean): Promise { + const args = this.buildCommand({ packages, upgrade }); + + await runPython( + this.pythonExecutable, // conda executable + args, + undefined, + this.log, + this.cancellationToken, + this.settings.executionTimeout, + ); + } +} +``` + +## Separation of Concerns + +### Persisting Arguments (Constructor) + +- Loaded once, reused across multiple executions +- Stored as instance properties +- Examples: `pythonExecutable`, `indexUrl`, `settings`, `log` + +### Ephemeral Arguments (Execute) + +- Change per invocation +- Passed to `execute()` method +- Examples: `packages`, `packageName`, `pythonVersion`, `upgrade` + +```typescript +// Constructor: load persisting config +const install = new PipInstallCommand({ + pythonExecutable: '/usr/bin/python3', + log: logger, +}); + +// execute(): pass ephemeral args +await install.execute([{ packageName: 'numpy' }], true); +await install.execute([{ packageName: 'pandas' }], false); // Same indexUrl reused +``` + +## Usage Flow + +1. **Executor creates command instance** with persisting options: + + ```typescript + const install = new PipInstallCommand({ + pythonExecutable, + log: context.log, + cancellationToken: context.cancellationToken, + }); + ``` + +2. **Constructor**: + - Calls `super(options)` to set pythonExecutable, log, cancellationToken + - Loads indexUrl from global config (persisting) + - Loads command-specific settings (timeout, retry, verbose) + +3. **Caller invokes execute()** with ephemeral args: + + ```typescript + await install.execute(packages, upgrade); + ``` + +4. **execute()**: + - Calls `buildCommand()` with ephemeral args + - Calls `runPython()` directly (no intermediate executeCommand function) + - Settings applied via `this.settings.executionTimeout` + +## Command Files + +| File | Template | Concrete(s) | +| ---------------------- | -------------------------- | --------------------------------------------------------- | +| `commandSettings.ts` | — | `PackageManagerCommand` base, `CommandSettings` interface | +| `install.ts` | `InstallCommand` | `PipInstallCommand` | +| `uninstall.ts` | `UninstallCommand` | `PipUninstallCommand` | +| `list.ts` | `ListCommand` | `PipListCommand` | +| `version.ts` | `VersionCommand` | `PipVersionCommand` | +| `availableVersions.ts` | `AvailableVersionsCommand` | `PipAvailableVersionsCommand` | +| `listDirectNames.ts` | `ListDirectNamesCommand` | `PipListDirectNamesCommand` | + +## Future: Conda and Poetry + +When extending to conda and poetry, simply add new concrete classes: + +```typescript +// In conda/commands/install.ts +export class CondaInstallCommand extends InstallCommand { + protected buildCommand(ephemeralArgs: InstallEphemeralArgs): string[] { + // conda-specific argument building + } + async execute(packages, upgrade) { + // conda-specific execution + } +} + +// In poetry/commands/install.ts +export class PoetryInstallCommand extends InstallCommand { + protected buildCommand(ephemeralArgs: InstallEphemeralArgs): string[] { + // poetry-specific argument building + } + async execute(packages, upgrade) { + // poetry-specific execution + } +} +``` + +Same template interface, different implementations per package manager. + +## Key Design Decisions + +✅ **Three-level hierarchy**: Base → Template → Concrete +✅ **Persisting vs ephemeral**: Constructor for config, execute() for data +✅ **Settings auto-load**: Each template loads its own command-specific settings +✅ **Direct runPython**: No executeCommand intermediate function +✅ **Command-specific indexUrl**: Only loaded by install commands, others ignore +✅ **No stored results**: Commands return data directly, don't cache on instance +✅ **Extensible**: Easy to add conda, poetry, uv variants by extending templates + +## Executor Integration + +**File**: `src/managers/builtin/commands/builtinCommandExecutor.ts` + +```typescript +export class BuiltinCommandExecutor { + async executeCommands( + environment: PythonEnvironment, + commands: BuiltinManageCommand[], + context: BuiltinCommandExecutionContext, + ): Promise { + const pythonExecutable = environment.execInfo?.run?.executable ?? 'python'; + + for (const command of commands) { + await this.executeCommand(pythonExecutable, command, context); + } + } + + private async executeCommand( + pythonExecutable: string, + command: BuiltinManageCommand, + context: BuiltinCommandExecutionContext, + ): Promise { + if (command.kind === 'install') { + // Create concrete class with persisting options + const install = new PipInstallCommand({ + pythonExecutable, + log: context.log, + cancellationToken: context.cancellationToken, + }); + // Execute with ephemeral args + await install.execute(command.payload.packages, command.payload.upgrade); + return; + } + // Similar for uninstall, list, etc. + } +} +``` + +## Architecture Components + +### 1. Base Class + +**File**: `src/managers/builtin/commands/commandSettings.ts` diff --git a/package-lock.json b/package-lock.json index 1ac3bcbf..0d079c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6913,7 +6913,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "node_modules/tunnel": { "version": "0.0.6", @@ -12366,7 +12367,8 @@ "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "tunnel": { "version": "0.0.6", diff --git a/package.json b/package.json index 5830cc3e..6730ea14 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,192 @@ "description": "%python-envs.packageWatchers.description%", "default": true, "scope": "machine" + }, + "python-envs.packageManager.installCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.installCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the install command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the install command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false + }, + "python-envs.packageManager.uninstallCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.uninstallCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the uninstall command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the uninstall command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false + }, + "python-envs.packageManager.listCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.listCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the list command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the list command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false + }, + "python-envs.packageManager.versionCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.versionCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the version command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the version command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false + }, + "python-envs.packageManager.availableVersionsCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.availableVersionsCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the availableVersions command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the availableVersions command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false + }, + "python-envs.packageManager.listDirectNamesCommandArgs": { + "type": "object", + "markdownDescription": "%python-envs.packageManager.listDirectNamesCommandArgs.description%", + "scope": "machine", + "properties": { + "executionTimeout": { + "type": "integer", + "description": "Timeout in milliseconds (0 = no timeout). Default: 5 minutes.", + "default": 300000, + "minimum": 0 + }, + "verboseOutput": { + "type": "boolean", + "description": "Display full output from the listDirectNames command.", + "default": false + }, + "retryOnFailure": { + "type": "boolean", + "description": "Retry the listDirectNames command once on failure.", + "default": true + }, + "maxRetries": { + "type": "integer", + "description": "Maximum number of retry attempts (0-3).", + "default": 1, + "minimum": 0, + "maximum": 3 + } + }, + "additionalProperties": false } } }, diff --git a/package.nls.json b/package.nls.json index 483ecfd2..d1d81e80 100644 --- a/package.nls.json +++ b/package.nls.json @@ -47,5 +47,11 @@ "python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...", "python-envs.managePackageVersion.title": "Manage Package Version", "python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv.", - "python-envs.packageWatchers.description": "When enabled, file system watchers monitor site-packages for install/uninstall changes and automatically refresh the package list. Disable this if you experience performance issues in large workspaces." + "python-envs.packageWatchers.description": "When enabled, file system watchers monitor site-packages for install/uninstall changes and automatically refresh the package list. Disable this if you experience performance issues in large workspaces.", + "python-envs.packageManager.installCommandArgs.description": "Arguments and settings for the install command (pip/uv install).", + "python-envs.packageManager.uninstallCommandArgs.description": "Arguments and settings for the uninstall command (pip/uv uninstall).", + "python-envs.packageManager.listCommandArgs.description": "Arguments and settings for the list command (pip/uv list).", + "python-envs.packageManager.versionCommandArgs.description": "Arguments and settings for the version command (pip/uv --version).", + "python-envs.packageManager.availableVersionsCommandArgs.description": "Arguments and settings for the availableVersions command (pip index versions).", + "python-envs.packageManager.listDirectNamesCommandArgs.description": "Arguments and settings for the listDirectNames command (pip list --not-required)." } diff --git a/src/api.ts b/src/api.ts index 1a9155b5..90bb7939 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1407,3 +1407,152 @@ export interface PythonEnvironmentApi PythonProjectApi, PythonExecutionApi, PythonEnvironmentVariablesApi {} + +// ============================================================================ +// Package Manager Command Classes (Base Classes for Extension Developers) +// ============================================================================ + +/** + * Base class for all package manager commands. + * + * This is the foundation for implementing command execution across different package managers + * (pip, UV, conda, poetry). Extensions can extend these classes to integrate with package + * manager operations or implement custom command types. + * + * @see {@link InstallCommand} + * @see {@link UninstallCommand} + * @see {@link ListCommand} + * @see {@link VersionCommand} + * @see {@link AvailableVersionsCommand} + * @see {@link ListDirectNamesCommand} + */ +export type { + BaseExecuteArgs, + CommandConstructorOptions, + PackageManagerCommand, +} from './managers/base/commands/packageManagerCommand'; + +/** + * Abstract base class for install command implementations. + * + * Implement this interface to support package installation in your package manager. + * The command handles executing the install operation and returning the exit code. + * + * @example + * ```typescript + * class MyInstallCommand extends InstallCommand { + * async execute(): Promise { + * // Implementation: run package install command + * return exitCode; + * } + * } + * ``` + * + * @see {@link PackageManager.manage} + */ +export type { InstallCommand } from './managers/base/commands/install'; + +/** + * Abstract base class for uninstall command implementations. + * + * Implement this interface to support package uninstallation in your package manager. + * The command handles executing the uninstall operation and returning the exit code. + * + * @example + * ```typescript + * class MyUninstallCommand extends UninstallCommand { + * async execute(): Promise { + * // Implementation: run package uninstall command + * return exitCode; + * } + * } + * ``` + * + * @see {@link PackageManager.manage} + */ +export type { UninstallCommand } from './managers/base/commands/uninstall'; + +/** + * Abstract base class for list command implementations. + * + * Implement this interface to retrieve the list of installed packages. + * The command executes and returns a collection of Package objects. + * + * @example + * ```typescript + * class MyListCommand extends ListCommand { + * async execute(): Promise { + * // Implementation: run list command and parse output + * return packages; + * } + * } + * ``` + * + * @see {@link PackageManager.getPackages} + */ +export type { ListCommand } from './managers/base/commands/list'; + +/** + * Abstract base class for version command implementations. + * + * Implement this interface to retrieve the version of the underlying package management tool. + * Returns a semantic version object. + * + * @example + * ```typescript + * class MyVersionCommand extends VersionCommand { + * async execute(): Promise { + * // Implementation: run version command and parse output + * return versionObject; + * } + * } + * ``` + * + * @see {@link PackageManager.getVersion} + */ +export type { VersionCommand } from './managers/base/commands/version'; + +/** + * Abstract base class for available versions command implementations. + * + * Implement this interface to retrieve the list of available versions for a package. + * Returns an array of semantic version objects sorted newest first. + * + * @example + * ```typescript + * class MyAvailableVersionsCommand extends AvailableVersionsCommand { + * async execute(): Promise { + * // Implementation: query PyPI or other registry for available versions + * return versions; + * } + * } + * ``` + * + * @see {@link PackageManager.getPackageAvailableVersions} + */ +export type { AvailableVersionsCommand } from './managers/base/commands/availableVersions'; + +/** + * Abstract base class for list direct names command implementations. + * + * Implement this interface to retrieve the names of direct (non-transitive) packages + * installed in an environment. Returns a set of package names. + * + * @remarks + * Most package managers cannot track user install intent. For pip, this uses + * `pip list --not-required` which returns packages with no installed dependents, + * not necessarily packages the user explicitly installed. This is a best-effort approximation. + * + * @example + * ```typescript + * class MyListDirectNamesCommand extends ListDirectNamesCommand { + * async execute(): Promise> { + * // Implementation: run command and return direct package names + * return packageNames; + * } + * } + * ``` + * + * @see {@link PackageManager.getDirectPackageNames} + */ +export type { ListDirectNamesCommand } from './managers/base/commands/listDirectNames'; diff --git a/src/managers/base/commands/availableVersions.ts b/src/managers/base/commands/availableVersions.ts new file mode 100644 index 00000000..cab9f6df --- /dev/null +++ b/src/managers/base/commands/availableVersions.ts @@ -0,0 +1,24 @@ +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Arguments for available versions command execution (change per execution). + */ +export interface AvailableVersionsExecuteArgs extends BaseExecuteArgs { + packageName: string; + pythonVersion: string; + includePrerelease?: boolean; +} + +/** + * Template class for availableVersions commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class AvailableVersionsCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'availableVersionsCommandArgs' }); + } + + protected abstract buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[]; + + abstract execute(executeArgs: AvailableVersionsExecuteArgs): Promise; +} diff --git a/src/managers/base/commands/index.ts b/src/managers/base/commands/index.ts new file mode 100644 index 00000000..41a16fd4 --- /dev/null +++ b/src/managers/base/commands/index.ts @@ -0,0 +1,7 @@ +export { AvailableVersionsCommand, type AvailableVersionsExecuteArgs } from './availableVersions'; +export { InstallCommand, type InstallExecuteArgs } from './install'; +export { ListCommand } from './list'; +export { ListDirectNamesCommand } from './listDirectNames'; +export { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; +export { UninstallCommand, type UninstallExecuteArgs } from './uninstall'; +export { VersionCommand } from './version'; diff --git a/src/managers/base/commands/install.ts b/src/managers/base/commands/install.ts new file mode 100644 index 00000000..3f635b10 --- /dev/null +++ b/src/managers/base/commands/install.ts @@ -0,0 +1,23 @@ +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Arguments for install command execution (change per execution). + */ +export interface InstallExecuteArgs extends BaseExecuteArgs { + packages: { packageName: string; version?: string }[]; + upgrade?: boolean; +} + +/** + * Template class for install commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class InstallCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'installCommandArgs' }); + } + + protected abstract buildCommand(executeArgs: InstallExecuteArgs): string[]; + + abstract execute(executeArgs: InstallExecuteArgs): Promise; +} diff --git a/src/managers/base/commands/list.ts b/src/managers/base/commands/list.ts new file mode 100644 index 00000000..0a8077cc --- /dev/null +++ b/src/managers/base/commands/list.ts @@ -0,0 +1,14 @@ +import { PackageInfo } from '../../../api'; +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for list commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class ListCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'listCommandArgs' }); + } + + abstract execute(executeArgs?: BaseExecuteArgs): Promise; +} diff --git a/src/managers/base/commands/listDirectNames.ts b/src/managers/base/commands/listDirectNames.ts new file mode 100644 index 00000000..793b92b9 --- /dev/null +++ b/src/managers/base/commands/listDirectNames.ts @@ -0,0 +1,13 @@ +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for listDirectNames commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class ListDirectNamesCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'listDirectNamesCommandArgs' }); + } + + abstract execute(executeArgs?: BaseExecuteArgs): Promise; +} diff --git a/src/managers/base/commands/packageManagerCommand.ts b/src/managers/base/commands/packageManagerCommand.ts new file mode 100644 index 00000000..870d1918 --- /dev/null +++ b/src/managers/base/commands/packageManagerCommand.ts @@ -0,0 +1,71 @@ +import { CancellationToken, l10n, LogOutputChannel, ProgressLocation, window, WorkspaceConfiguration } from 'vscode'; +import { getConfiguration } from '../../../common/workspace.apis'; + +/** + * Base interface for all command execute arguments. + * Provides optional cancellation token that all commands can use. + */ +export interface BaseExecuteArgs { + cancellationToken?: CancellationToken; + showProgress?: boolean; +} + +/** + * Constructor options shared by all package manager commands. + */ +export interface CommandConstructorOptions { + pythonExecutable: string; + configSection?: string; + log?: LogOutputChannel; +} + +/** + * Base class for all package manager commands. + * Provides common properties and minimal interface for subclasses. + */ +export abstract class PackageManagerCommand { + protected pythonExecutable: string; + protected log?: LogOutputChannel; + protected timeout: number = 300000; + protected config: WorkspaceConfiguration; + + constructor(options: CommandConstructorOptions) { + this.pythonExecutable = options.pythonExecutable; + this.log = options.log; + this.config = options.configSection + ? getConfiguration(`python-envs.packageManager.${options.configSection}`) + : getConfiguration('python-envs.packageManager'); + } + + /** + * Executes this command and optionally wraps execution with a progress indicator. + */ + public executeWithProgress( + executeArgs?: A, + title?: string, + ): Promise { + if (!executeArgs?.showProgress) { + return this.execute(executeArgs) as Promise; + } + + return Promise.resolve( + window.withProgress( + { + location: ProgressLocation.Notification, + title: title ?? l10n.t('Running package manager command'), + }, + () => this.execute(executeArgs) as Promise, + ), + ); + } + + /** + * Subclasses implement command execution. + */ + abstract execute(executeArgs?: BaseExecuteArgs): Promise; + + /** + * Subclasses implement to build the command arguments. + */ + protected abstract buildCommand(executeArgs: BaseExecuteArgs): string[]; +} diff --git a/src/managers/base/commands/uninstall.ts b/src/managers/base/commands/uninstall.ts new file mode 100644 index 00000000..31633868 --- /dev/null +++ b/src/managers/base/commands/uninstall.ts @@ -0,0 +1,22 @@ +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Arguments for uninstall command execution (change per execution). + */ +export interface UninstallExecuteArgs extends BaseExecuteArgs { + packages: { packageName: string; version?: string }[]; +} + +/** + * Template class for uninstall commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class UninstallCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'uninstallCommandArgs' }); + } + + protected abstract buildCommand(executeArgs: UninstallExecuteArgs): string[]; + + abstract execute(executeArgs: UninstallExecuteArgs): Promise; +} diff --git a/src/managers/base/commands/version.ts b/src/managers/base/commands/version.ts new file mode 100644 index 00000000..1ffc6d3b --- /dev/null +++ b/src/managers/base/commands/version.ts @@ -0,0 +1,13 @@ +import { BaseExecuteArgs, CommandConstructorOptions, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for version commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class VersionCommand extends PackageManagerCommand { + constructor(options: CommandConstructorOptions) { + super({ ...options, configSection: 'versionCommandArgs' }); + } + + abstract execute(executeArgs?: BaseExecuteArgs): Promise; +} diff --git a/src/managers/builtin/commands/availableVersions.ts b/src/managers/builtin/commands/availableVersions.ts new file mode 100644 index 00000000..f03faf13 --- /dev/null +++ b/src/managers/builtin/commands/availableVersions.ts @@ -0,0 +1,119 @@ +import { + AvailableVersionsCommand, + CommandConstructorOptions, + type AvailableVersionsExecuteArgs, +} from '../../base/commands/index'; +import { runPython } from '../helpers'; + +/** + * Pip available versions command. + * + * Parsed Command: `python -m pip index versions --json --python-version ` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip_index/ + * The `pip index versions` command lists all available versions of a package on PyPI. + * The `--python-version` flag filters versions compatible with the specified Python version. + * The `--json` flag outputs results in JSON format for structured parsing. + */ +export class PipAvailableVersionsCommand extends AvailableVersionsCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + const baseVersion = executeArgs.pythonVersion.split('.').slice(0, 2).join('.'); + return ['-m', 'pip', 'index', 'versions', executeArgs.packageName, '--json', '--python-version', baseVersion]; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + let availableVersions: string[] = []; + + const parser = (output: string): void => { + const match = output.match(/{[\s\S]*}/); + if (!match) { + availableVersions = []; + return; + } + try { + const parsed = JSON.parse(match[0]) as { versions?: string[] }; + let versions = Array.isArray(parsed.versions) ? parsed.versions.filter((v) => !!v.trim()) : []; + if (!executeArgs.includePrerelease) { + versions = versions.filter((version) => !/[ab]|rc|dev/i.test(version)); + } + availableVersions = versions; + } catch { + availableVersions = []; + } + }; + + const args = this.buildCommand(executeArgs); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout, + ); + + parser(output); + return availableVersions; + } +} + +/** + * UV available versions command. + * + * Parsed Command: `uv pip index versions --json --python-version ` + * + * Official Documentation: https://docs.astral.sh/uv/pip/ + * The `uv pip index versions` command lists all available versions of a package. + * The `--python-version` flag filters versions compatible with the specified Python version. + * The `--json` flag outputs results in JSON format for structured parsing. + */ +export class UvAvailableVersionsCommand extends AvailableVersionsCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + const baseVersion = executeArgs.pythonVersion.split('.').slice(0, 2).join('.'); + return ['pip', 'index', 'versions', executeArgs.packageName, '--json', '--python-version', baseVersion]; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + let availableVersions: string[] = []; + + const parser = (output: string): void => { + const match = output.match(/{[\s\S]*}/); + if (!match) { + availableVersions = []; + return; + } + try { + const parsed = JSON.parse(match[0]) as { versions?: string[] }; + let versions = Array.isArray(parsed.versions) ? parsed.versions.filter((v) => !!v.trim()) : []; + if (!executeArgs.includePrerelease) { + versions = versions.filter((version) => !/[ab]|rc|dev/i.test(version)); + } + availableVersions = versions; + } catch { + availableVersions = []; + } + }; + + const args = this.buildCommand(executeArgs); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout, + ); + + parser(output); + return availableVersions; + } +} diff --git a/src/managers/builtin/commands/index.ts b/src/managers/builtin/commands/index.ts new file mode 100644 index 00000000..0f15cf97 --- /dev/null +++ b/src/managers/builtin/commands/index.ts @@ -0,0 +1,6 @@ +export { PipAvailableVersionsCommand, UvAvailableVersionsCommand } from './availableVersions'; +export { PipInstallCommand, UvInstallCommand } from './install'; +export { PipListCommand, UvListCommand } from './list'; +export { PipListDirectNamesCommand, UvListDirectNamesCommand } from './listDirectNames'; +export { PipUninstallCommand, UvUninstallCommand } from './uninstall'; +export { PipVersionCommand, UvVersionCommand } from './version'; diff --git a/src/managers/builtin/commands/install.ts b/src/managers/builtin/commands/install.ts new file mode 100644 index 00000000..f67bda6e --- /dev/null +++ b/src/managers/builtin/commands/install.ts @@ -0,0 +1,89 @@ +import { getConfiguration } from '../../../common/workspace.apis'; +import { CommandConstructorOptions, InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runPython } from '../helpers'; +import { processEditableInstallArgs } from '../utils'; + +/** + * Pip install command. + * + * Parsed Command: `python -m pip install [--upgrade] [--index-url ] ` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip_install/ + * The `pip install` command installs packages from the Python Package Index (PyPI). + * Supports version pinning via `package==version` syntax and index URL configuration. + */ +export class PipInstallCommand extends InstallCommand { + private indexUrl?: string; + + constructor(options: CommandConstructorOptions) { + super(options); + const config = getConfiguration('python-envs.packageManager'); + this.indexUrl = config.get('indexUrl'); + } + + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + let args = ['-m', 'pip', 'install']; + + if (this.indexUrl) { + args.push('--index-url', this.indexUrl); + } + + if (executeArgs.upgrade) { + args.push('--upgrade'); + } + + const processedArgs = processEditableInstallArgs(executeArgs.packages.map((pkg) => pkg.packageName)); + args.push(...processedArgs); + + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runPython(this.pythonExecutable, args, undefined, this.log, executeArgs.cancellationToken, this.timeout); + } +} + +/** + * UV install command. + * + * Parsed Command: `uv pip install --python [--upgrade] [--index-url ] ` + * + * Official Documentation: https://docs.astral.sh/uv/pip/ + * The `uv pip install` command is UV's high-performance Python package installer. + * UV is a Rust-based pip replacement that's faster than traditional pip. + * The `--python` flag specifies the target Python interpreter. + */ +export class UvInstallCommand extends InstallCommand { + private indexUrl?: string; + + constructor(options: CommandConstructorOptions) { + super(options); + const config = getConfiguration('python-envs.packageManager'); + this.indexUrl = config.get('indexUrl'); + } + + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + let args = ['pip', 'install', '--python', this.pythonExecutable]; + + if (this.indexUrl) { + args.push('--index-url', this.indexUrl); + } + + if (executeArgs.upgrade) { + args.push('--upgrade'); + } + + const processedArgs = processEditableInstallArgs(executeArgs.packages.map((pkg) => pkg.packageName)); + args.push(...processedArgs); + + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runPython(this.pythonExecutable, args, undefined, this.log, executeArgs.cancellationToken, this.timeout); + } +} diff --git a/src/managers/builtin/commands/list.ts b/src/managers/builtin/commands/list.ts new file mode 100644 index 00000000..70239a16 --- /dev/null +++ b/src/managers/builtin/commands/list.ts @@ -0,0 +1,108 @@ +import { PackageInfo } from '../../../api'; +import { CommandConstructorOptions, ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython } from '../helpers'; + +/** + * Pip list command. + * + * Parsed Command: `python -m pip list --format=json` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip_list/ + * The `pip list` command shows all installed packages in the current environment. + * The `--format=json` flag outputs the list in JSON format for structured parsing. + */ +export class PipListCommand extends ListCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + protected buildCommand(): string[] { + return ['-m', 'pip', 'list', '--format=json']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const packages: PackageInfo[] = []; + + const parser = (output: string): void => { + const json = JSON.parse(output); + if (!Array.isArray(json)) { + throw new Error('Invalid output from pip list command'); + } + const parsed = json + .filter(({ name, version }) => name && version) + .map(({ name, version }) => ({ + name, + version, + displayName: name, + description: version, + })); + packages.push(...parsed); + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return packages; + } +} + +/** + * UV list command. + * + * Parsed Command: `uv pip list --format=json` + * + * Official Documentation: https://docs.astral.sh/uv/pip/ + * The `uv pip list` command shows all installed packages via UV's pip interface. + * The `--format=json` flag outputs the list in JSON format for structured parsing. + */ +export class UvListCommand extends ListCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['pip', 'list', '--format=json']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const packages: PackageInfo[] = []; + + const parser = (output: string): void => { + const json = JSON.parse(output); + if (!Array.isArray(json)) { + throw new Error('Invalid output from uv pip list command'); + } + const parsed = json + .filter(({ name, version }) => name && version) + .map(({ name, version }) => ({ + name, + version, + displayName: name, + description: version, + })); + packages.push(...parsed); + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return packages; + } +} diff --git a/src/managers/builtin/commands/listDirectNames.ts b/src/managers/builtin/commands/listDirectNames.ts new file mode 100644 index 00000000..0acb195c --- /dev/null +++ b/src/managers/builtin/commands/listDirectNames.ts @@ -0,0 +1,93 @@ +import { CommandConstructorOptions, ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython } from '../helpers'; + +/** + * Pip list direct names command. + * + * Parsed Command: `python -m pip list --format=json --not-required` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip_list/ + * The `pip list --not-required` command lists only top-level (directly installed) packages. + * Excludes transitive dependencies that are installed as requirements of other packages. + * The `--format=json` flag outputs results in JSON format for structured parsing. + */ +export class PipListDirectNamesCommand extends ListDirectNamesCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + protected buildCommand(): string[] { + return ['-m', 'pip', 'list', '--format=json', '--not-required']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + let directNames: string[] = []; + + const parser = (output: string): void => { + const packages = JSON.parse(output); + if (!Array.isArray(packages)) { + throw new Error('Invalid output from pip list command'); + } + directNames = packages.filter(({ name }) => name).map(({ name }) => name); + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return directNames; + } +} + +/** + * UV list direct names command. + * + * Parsed Command: `uv pip list --format=json --not-required` + * + * Official Documentation: https://docs.astral.sh/uv/pip/ + * The `uv pip list --not-required` command lists only top-level (directly installed) packages. + * Excludes transitive dependencies that are installed as requirements of other packages. + * The `--format=json` flag outputs results in JSON format for structured parsing. + */ +export class UvListDirectNamesCommand extends ListDirectNamesCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['pip', 'list', '--format=json', '--not-required']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + let directNames: string[] = []; + + const parser = (output: string): void => { + const packages = JSON.parse(output); + if (!Array.isArray(packages)) { + throw new Error('Invalid output from uv pip list command'); + } + directNames = packages.filter(({ name }) => name).map(({ name }) => name); + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return directNames; + } +} diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts new file mode 100644 index 00000000..e3e5af72 --- /dev/null +++ b/src/managers/builtin/commands/uninstall.ts @@ -0,0 +1,54 @@ +import { CommandConstructorOptions, UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runPython } from '../helpers'; + +/** + * Pip uninstall command. + * + * Parsed Command: `python -m pip uninstall -y ` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip_uninstall/ + * The `pip uninstall` command uninstalls installed packages from the current environment. + * The `-y` flag automatically confirms the uninstallation without prompting. + */ +export class PipUninstallCommand extends UninstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + return ['-m', 'pip', 'uninstall', '-y', ...executeArgs.packages.map((pkg) => pkg.packageName)]; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runPython(this.pythonExecutable, args, undefined, this.log, executeArgs.cancellationToken, this.timeout); + } +} + +/** + * UV uninstall command. + * + * Parsed Command: `uv pip uninstall -y --python ` + * + * Official Documentation: https://docs.astral.sh/uv/pip/ + * The `uv pip uninstall` command removes packages from the Python environment via UV. + * The `-y` flag automatically confirms uninstallation without prompting. + * The `--python` flag specifies the target Python interpreter. + */ +export class UvUninstallCommand extends UninstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + const args = ['pip', 'uninstall', '-y', '--python', this.pythonExecutable]; + args.push(...executeArgs.packages.map((pkg) => pkg.packageName)); + return args; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runPython(this.pythonExecutable, args, undefined, this.log, executeArgs.cancellationToken, this.timeout); + } +} diff --git a/src/managers/builtin/commands/version.ts b/src/managers/builtin/commands/version.ts new file mode 100644 index 00000000..175f5f97 --- /dev/null +++ b/src/managers/builtin/commands/version.ts @@ -0,0 +1,87 @@ +import { CommandConstructorOptions, VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython } from '../helpers'; + +/** + * Pip version command. + * + * Parsed Command: `python -m pip --version` + * + * Official Documentation: https://pip.pypa.io/en/stable/cli/pip/ + * The `pip --version` command displays the current version of pip. + * Output format: "pip X.Y.Z from /path/to/pip (python X.Y)" + */ +export class PipVersionCommand extends VersionCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + protected buildCommand(): string[] { + return ['-m', 'pip', '--version']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + let versionString: string = ''; + + const parser = (output: string): void => { + // "pip X.Y.Z from /path/to/pip (python X.Y)" + const match = output.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); + versionString = match ? match[1] : ''; + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return versionString; + } +} + +/** + * UV version command. + * + * Parsed Command: `uv --version` + * + * Official Documentation: https://docs.astral.sh/uv/ + * The `uv --version` command displays the current version of UV. + * Output format: "uv X.Y.Z" + */ +export class UvVersionCommand extends VersionCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + let versionString: string = ''; + + const parser = (output: string): void => { + // "uv X.Y.Z" format + const match = output.match(/(\d+\.\d+(?:\.\d+)*)/); + versionString = match ? match[1] : ''; + }; + + const args = this.buildCommand(); + + const output = await runPython( + this.pythonExecutable, + args, + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + parser(output); + return versionString; + } +} diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 911bc603..c5f238c8 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -131,9 +131,7 @@ export async function runPython( log?.append(`python: ${s}`); }); proc.stderr?.on('data', (data) => { - const s = data.toString('utf-8'); - builder += s; - log?.append(`python: ${s}`); + log?.append(data.toString('utf-8')); }); proc.on('close', () => { resolve(builder); diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index f0e09a7d..249edfb2 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -1,7 +1,6 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { compare, explain as parse, rcompare } from '@renovatebot/pep440'; +import { compare, explain as parse } from '@renovatebot/pep440'; import { - CancellationError, Disposable, Event, EventEmitter, @@ -12,6 +11,7 @@ import { window, } from 'vscode'; import { + CommandConstructorOptions, DidChangePackagesEventArgs, GetPackagesOptions, IconPath, @@ -22,9 +22,23 @@ import { PythonEnvironmentApi, } from '../../api'; import { updatePackagesAndNotify } from '../common/packageChanges'; -import { runPython, runUV, shouldUseUv } from './helpers'; +import { + PipAvailableVersionsCommand, + PipInstallCommand, + PipListCommand, + PipListDirectNamesCommand, + PipUninstallCommand, + PipVersionCommand, + UvAvailableVersionsCommand, + UvInstallCommand, + UvListCommand, + UvListDirectNamesCommand, + UvUninstallCommand, + UvVersionCommand, +} from './commands/index'; +import { shouldUseUv } from './helpers'; import { getWorkspacePackagesToInstall } from './pipUtils'; -import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils'; +import { normalizePackageName, parsePackageSpecs } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -65,43 +79,53 @@ export class PipPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Installing packages', - cancellable: true, - }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages', e); - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - throw e; + try { + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error('Unable to determine Python executable path'); + } + + // Detect whether to use UV + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + + // Centralize command options for install/uninstall operations + const manageCommandOptions: CommandConstructorOptions = { + pythonExecutable, + log: this.log, + }; + + // Execute uninstall if needed + if (toUninstall.length > 0) { + const UninstallCommand = useUv ? UvUninstallCommand : PipUninstallCommand; + const uninstallCmd = new UninstallCommand(manageCommandOptions); + const packages = parsePackageSpecs(toUninstall); + await uninstallCmd.executeWithProgress({ packages, showProgress: true }, 'Installing packages'); + } + + // Execute install if needed + if (toInstall.length > 0) { + const InstallCommand = useUv ? UvInstallCommand : PipInstallCommand; + const installCmd = new InstallCommand(manageCommandOptions); + const packages = parsePackageSpecs(toInstall); + await installCmd.executeWithProgress( + { packages, upgrade: options.upgrade, showProgress: true }, + 'Installing packages', + ); + } + + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + this.log.error('Error managing packages', e); + setImmediate(async () => { + const result = await window.showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); } - }, - ); + }); + throw e; + } } async refresh(environment: PythonEnvironment): Promise { @@ -125,7 +149,18 @@ export class PipPackageManager implements PackageManager, Disposable { async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const data = await refreshPipPackages(environment, this.log); + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; + } + + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + const ListCmd = useUv ? UvListCommand : PipListCommand; + const listCmd = new ListCmd({ + pythonExecutable, + log: this.log, + }); + const data = await listCmd.execute(); const packages = (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); this.packages.set(environment.envId.id, packages); return packages; @@ -135,22 +170,19 @@ export class PipPackageManager implements PackageManager, Disposable { async getVersion(environment: PythonEnvironment): Promise { try { - const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const result = await runUV(['--version'], undefined, this.log); - // "uv X.Y.Z" - const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/); - return match ? (parse(match[1]) ?? undefined) : undefined; + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; } - const result = await runPython( - environment.execInfo?.run?.executable ?? 'python', - ['-m', 'pip', '--version'], - undefined, - this.log, - ); - // "pip X.Y.Z from /path/to/pip (python X.Y)" - const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); - return match ? (parse(match[1]) ?? undefined) : undefined; + + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + const VersionCmd = useUv ? UvVersionCommand : PipVersionCommand; + const versionCmd = new VersionCmd({ + pythonExecutable, + log: this.log, + }); + const versionString = await versionCmd.execute(); + return versionString ? (parse(versionString) ?? undefined) : undefined; } catch { return undefined; } @@ -161,8 +193,8 @@ export class PipPackageManager implements PackageManager, Disposable { packageName: string, ): Promise { try { - const python = environment.execInfo?.run?.executable; - if (!python) { + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { return undefined; } @@ -170,30 +202,28 @@ export class PipPackageManager implements PackageManager, Disposable { if (!baseVersion) { return undefined; } - // uv - Run pip via `uv tool run pip` + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const output = await runUV( - ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); - } + const AvailableVersionsCmd = useUv ? UvAvailableVersionsCommand : PipAvailableVersionsCommand; + const availableVersionsCmd = new AvailableVersionsCmd({ + pythonExecutable, + log: this.log, + }); - // pip >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. - const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); + // For pip < 21.2.0, check version first + if (!useUv) { + const pipVersion = await this.getVersion(environment); + if (!pipVersion || compare(pipVersion.public, '21.2.0') < 0) { + // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + return undefined; + } } - // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + const versionStrings = await availableVersionsCmd.execute({ + packageName, + pythonVersion: environment.version, + }); + return versionStrings.map((v) => parse(v)).filter((parsed) => parsed !== undefined) as Pep440Version[]; } catch { return undefined; } @@ -205,38 +235,24 @@ export class PipPackageManager implements PackageManager, Disposable { } /** - * Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`. + * Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip list --not-required`. * * Note: These commands return packages with no installed dependents (leaf packages), not packages * the user explicitly installed. pip/uv do not track install intent. */ async getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { - const data = await refreshPipDirectPackageNames(environment, this.log); - return data ? new Set(data.map(normalizePackageName)) : undefined; - } -} - -/** - * Parses JSON output from `pip index versions --json`. - * Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] } - */ -export function parsePipIndexVersionsJson(output: string): Pep440Version[] | undefined { - // Only capture output between braces - const match = output.match(/{[\s\S]*}/); - if (!match) { - return undefined; - } - try { - const parsed = JSON.parse(match[0]); - if (parsed && Array.isArray(parsed.versions) && parsed.versions.length > 0) { - return (parsed.versions as string[]) - .filter((v) => !!v.trim()) - .map((v) => parse(v.trim())) - .filter((v): v is Pep440Version => v !== null) - .sort((a, b) => rcompare(a.public, b.public)); + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; } - return undefined; - } catch { - return undefined; + + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + const ListDirectNamesCmd = useUv ? UvListDirectNamesCommand : PipListDirectNamesCommand; + const listDirectNamesCmd = new ListDirectNamesCmd({ + pythonExecutable, + log: this.log, + }); + const data = await listDirectNamesCmd.execute(); + return data ? new Set(data.map(normalizePackageName)) : undefined; } } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index a2bc4a0f..254872bf 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -12,7 +12,8 @@ import { findFiles } from '../../common/workspace.apis'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; import { Installable } from '../common/types'; import { mergePackages } from '../common/utils'; -import { refreshPipPackages } from './utils'; +import { PipListCommand, UvListCommand } from './commands/index'; +import { shouldUseUv } from './helpers'; export interface PyprojectToml { project?: { @@ -250,7 +251,14 @@ export async function getWorkspacePackagesToInstall( let common = await getCommonPackages(); let installed: string[] | undefined; if (environment) { - installed = (await refreshPipPackages(environment, log, { showProgress: true }))?.map((pkg) => pkg.name); + const pythonExecutable = environment.execInfo?.run?.executable; + if (pythonExecutable) { + const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); + const ListCmd = useUv ? UvListCommand : PipListCommand; + const listCmd = new ListCmd({ pythonExecutable, log }); + const data = await listCmd.executeWithProgress<{ name: string }[]>({ showProgress: true }); + installed = data?.map((pkg) => pkg.name); + } common = mergePackages(common, installed ?? []); } return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []); @@ -390,17 +398,3 @@ export async function shouldProceedAfterPyprojectValidation( return false; } - -export function isPipInstallCommand(command: string): boolean { - // Regex to match pip install commands, capturing variations like: - // pip install package - // python -m pip install package - // pip3 install package - // py -m pip install package - // pip install -r requirements.txt - // uv pip install package - // poetry run pip install package - // pipx run pip install package - // Any other tool that might wrap pip install - return /(?:^|\s)(?:\S+\s+)*(?:pip\d*)\s+(install|uninstall)\b/.test(command); -} diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index dc44fe75..04634803 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -1,19 +1,10 @@ -import { CancellationToken, LogOutputChannel, ProgressLocation, QuickPickItem, Uri, window } from 'vscode'; -import { - EnvironmentManager, - Package, - PackageManagementOptions, - PackageManager, - PythonEnvironment, - PythonEnvironmentApi, - PythonEnvironmentInfo, -} from '../../api'; -import { showErrorMessageWithLogs } from '../../common/errors/utils'; +import { LogOutputChannel, QuickPickItem, Uri, window } from 'vscode'; +import { EnvironmentManager, Package, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; import { getExtension } from '../../common/extension.apis'; import { Common, PixiStrings, SysManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; -import { showInformationMessage, withProgress } from '../../common/window.apis'; +import { showInformationMessage } from '../../common/window.apis'; import { openExtension } from '../../common/workbenchCommands'; import { isNativeEnvInfo, @@ -22,13 +13,22 @@ import { NativePythonFinder, } from '../common/nativePythonFinder'; import { shortenVersionString, sortEnvironments } from '../common/utils'; -import { runPython, runUV, shouldUseUv } from './helpers'; -import { parsePipListJson, parseUvTree, PipPackage } from './pipListUtils'; const PIXI_EXTENSION_ID = 'renan-r-santos.pixi-code'; const PIXI_RECOMMEND_DONT_ASK_KEY = 'pixi-extension-recommend-dont-ask'; let pixiRecommendationShown = false; +/** + * Parse package specifications (strings) into package objects. + * Each string becomes a package object with packageName and empty version. + */ +export function parsePackageSpecs(packageStrings: string[]): { packageName: string; version?: string }[] { + return packageStrings.map((pkg) => ({ + packageName: pkg, + version: undefined, + })); +} + function asPackageQuickPickItem(name: string, version?: string): QuickPickItem { return { label: name, @@ -183,153 +183,6 @@ export async function refreshPythons( return sortEnvironments(collection); } -const PIP_LIST_TIMEOUT_MS = 30_000; - -async function execPipList(environment: PythonEnvironment, log?: LogOutputChannel, args?: string[]): Promise { - // Use environmentPath directly for consistency with UV environment tracking - const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); - if (useUv) { - return await runUV( - ['pip', 'list', '--python', environment.execInfo.run.executable, '--format=json', ...(args ?? [])], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - } - try { - return await runPython( - environment.execInfo.run.executable, - ['-m', 'pip', 'list', '--format=json', ...(args ?? [])], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - } catch (ex) { - log?.error('Error running pip list', ex); - log?.info( - 'Package list retrieval attempted using pip, action can be done with uv if installed and setting `alwaysUseUv` is enabled.', - ); - throw ex; - } -} - -export async function refreshPipPackages( - environment: PythonEnvironment, - log?: LogOutputChannel, - options?: { showProgress: boolean }, -): Promise { - let data: string; - try { - if (options?.showProgress) { - data = await withProgress( - { - location: ProgressLocation.Notification, - }, - async () => { - return await execPipList(environment, log); - }, - ); - } else { - data = await execPipList(environment, log); - } - - return parsePipListJson(data, log); - } catch (e) { - log?.error('Error refreshing packages', e); - showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); - return undefined; - } -} - -/** - * Returns names of packages with no installed dependents (leaf packages). - * - * Uses `pip list --not-required` (pip) or `uv pip tree --depth=0` (uv). These report - * packages that nothing else depends on, which is a proxy for "directly installed" but - * not equivalent — e.g., `pip install flask werkzeug` will report werkzeug as having - * dependents (flask) even though the user installed it explicitly. - */ -export async function refreshPipDirectPackageNames( - environment: PythonEnvironment, - log?: LogOutputChannel, -): Promise { - const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); - if (useUv) { - const treeOutput = await runUV( - ['pip', 'tree', '--python', environment.execInfo.run.executable, '--depth=0'], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - return parseUvTree(treeOutput); - } - const data = await execPipList(environment, log, ['--not-required']); - const packages = parsePipListJson(data); - return packages.map((pkg) => pkg.name); -} - -export async function managePackages( - environment: PythonEnvironment, - options: PackageManagementOptions, - manager: PackageManager, - token?: CancellationToken, -): Promise { - if (environment.version.startsWith('2.')) { - throw new Error('Python 2.* is not supported (deprecated)'); - } - - // Use environmentPath directly for consistency with UV environment tracking - const useUv = await shouldUseUv(manager.log, environment.environmentPath.fsPath); - const uninstallArgs = ['pip', 'uninstall']; - if (options.uninstall && options.uninstall.length > 0) { - if (useUv) { - await runUV( - [...uninstallArgs, '--python', environment.execInfo.run.executable, ...options.uninstall], - undefined, - manager.log, - token, - ); - } else { - uninstallArgs.push('--yes'); - await runPython( - environment.execInfo.run.executable, - ['-m', ...uninstallArgs, ...options.uninstall], - undefined, - manager.log, - token, - ); - } - } - - const installArgs = ['pip', 'install']; - if (options.upgrade) { - installArgs.push('--upgrade'); - } - if (options.install && options.install.length > 0) { - const processedInstallArgs = processEditableInstallArgs(options.install); - - if (useUv) { - await runUV( - [...installArgs, '--python', environment.execInfo.run.executable, ...processedInstallArgs], - undefined, - manager.log, - token, - ); - } else { - await runPython( - environment.execInfo.run.executable, - ['-m', ...installArgs, ...processedInstallArgs], - undefined, - manager.log, - token, - ); - } - } -} - /** * Process pip install arguments to correctly handle editable installs with extras * This function will combine consecutive -e arguments that represent the same package with extras diff --git a/src/managers/conda/commands/availableVersions.ts b/src/managers/conda/commands/availableVersions.ts new file mode 100644 index 00000000..0181c2db --- /dev/null +++ b/src/managers/conda/commands/availableVersions.ts @@ -0,0 +1,52 @@ +import { + AvailableVersionsCommand, + CommandConstructorOptions, + type AvailableVersionsExecuteArgs, +} from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda available versions command. + * + * Parsed Command: `conda search --json` + * + * Official Documentation: https://conda.io/projects/conda/en/latest/commands/search.html + * The `conda search` command searches for packages in the conda channels. + * The `--json` flag outputs results in JSON format for structured parsing. + * Returns all builds of all versions available; deduplication is performed in the command. + * NOTE: The pythonVersion parameter is ignored for conda (unlike pip) as conda doesn't filter by Python version. + */ +export class CondaAvailableVersionsCommand extends AvailableVersionsCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + return ['search', executeArgs.packageName, '--json']; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + const output = await runCondaExecutable(args, this.log, executeArgs.cancellationToken); + + try { + const parsed = JSON.parse(output); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed[executeArgs.packageName])) { + const uniqueVersions = new Map(); + (parsed[executeArgs.packageName] as Array<{ version?: string }>) + .filter((entry) => !!entry.version?.trim()) + .forEach((entry) => { + const version = entry.version!.trim(); + if (!uniqueVersions.has(version)) { + uniqueVersions.set(version, version); + } + }); + + return Array.from(uniqueVersions.values()); + } + return []; + } catch { + return []; + } + } +} diff --git a/src/managers/conda/commands/index.ts b/src/managers/conda/commands/index.ts new file mode 100644 index 00000000..705ba8d0 --- /dev/null +++ b/src/managers/conda/commands/index.ts @@ -0,0 +1,5 @@ +export { CondaAvailableVersionsCommand } from './availableVersions'; +export { CondaInstallCommand } from './install'; +export { CondaListCommand } from './list'; +export { CondaUninstallCommand } from './uninstall'; +export { CondaVersionCommand } from './version'; diff --git a/src/managers/conda/commands/install.ts b/src/managers/conda/commands/install.ts new file mode 100644 index 00000000..ccf14453 --- /dev/null +++ b/src/managers/conda/commands/install.ts @@ -0,0 +1,44 @@ +import { CommandConstructorOptions, InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda install command. + * + * Parsed Command: `conda install -y -c conda-forge [--upgrade] ` + * + * Official Documentation: https://conda.io/projects/conda/en/latest/commands/install.html + * The `conda install` command installs packages in the current conda environment. + * The `-y` flag automatically confirms the installation without prompting. + * The `-c conda-forge` flag specifies the conda-forge channel as the default package source. + * The `--upgrade` flag updates packages to their newest versions. + */ +export class CondaInstallCommand extends InstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + let args = ['install', '-y', '-c', 'conda-forge']; + + if (executeArgs.upgrade) { + args.push('--upgrade'); + } + + args.push( + ...executeArgs.packages.map((pkg) => { + if (pkg.version) { + return `${pkg.packageName}=${pkg.version}`; + } + return pkg.packageName; + }), + ); + + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runCondaExecutable(args, this.log, executeArgs.cancellationToken); + } +} diff --git a/src/managers/conda/commands/list.ts b/src/managers/conda/commands/list.ts new file mode 100644 index 00000000..60452581 --- /dev/null +++ b/src/managers/conda/commands/list.ts @@ -0,0 +1,62 @@ +import { PackageInfo } from '../../../api'; +import { CommandConstructorOptions, ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda list command execute arguments (includes environment path and cancellation token). + */ +interface CondaListExecuteArgs extends BaseExecuteArgs { + environmentPath: string; +} + +/** + * Conda list command. + * + * Parsed Command: `conda list -p --json` + * + * Official Documentation: https://conda.io/projects/conda/en/latest/commands/list.html + * The `conda list` command shows all installed packages in a conda environment. + * The `-p` flag specifies the environment path (can be absolute or relative). + * The `--json` flag outputs the package list in JSON format for structured parsing. + */ +export class CondaListCommand extends ListCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: BaseExecuteArgs): string[] { + const args = executeArgs as CondaListExecuteArgs; + return ['list', '-p', args.environmentPath, '--json']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const args = executeArgs as CondaListExecuteArgs | undefined; + if (!args?.environmentPath) { + return []; + } + + const cmdArgs = this.buildCommand(executeArgs!); + const output = await runCondaExecutable(cmdArgs, this.log, executeArgs?.cancellationToken); + + let condaPackages: { name: string; version: string }[]; + try { + condaPackages = JSON.parse(output) as { name: string; version: string }[]; + } catch { + return []; + } + + const packages: PackageInfo[] = []; + for (const condaPkg of condaPackages) { + if (condaPkg.name && condaPkg.version) { + packages.push({ + name: condaPkg.name, + displayName: condaPkg.name, + version: condaPkg.version, + description: condaPkg.version, + }); + } + } + + return packages; + } +} diff --git a/src/managers/conda/commands/uninstall.ts b/src/managers/conda/commands/uninstall.ts new file mode 100644 index 00000000..7911a744 --- /dev/null +++ b/src/managers/conda/commands/uninstall.ts @@ -0,0 +1,28 @@ +import { CommandConstructorOptions, UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda uninstall command. + * + * Parsed Command: `conda remove -y ` + * + * Official Documentation: https://conda.io/projects/conda/en/latest/commands/remove.html + * The `conda remove` command (alias `conda uninstall`) removes packages from the current environment. + * The `-y` flag automatically confirms the removal without prompting. + * Removes both the package and its unused dependencies by default. + */ +export class CondaUninstallCommand extends UninstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + return ['remove', '-y', ...executeArgs.packages.map((pkg) => pkg.packageName)]; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + + await runCondaExecutable(args, this.log, executeArgs.cancellationToken); + } +} diff --git a/src/managers/conda/commands/version.ts b/src/managers/conda/commands/version.ts new file mode 100644 index 00000000..42075068 --- /dev/null +++ b/src/managers/conda/commands/version.ts @@ -0,0 +1,30 @@ +import { CommandConstructorOptions, VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda version command. + * + * Parsed Command: `conda --version` + * + * Official Documentation: https://conda.io/projects/conda/en/latest/commands.html + * The `conda --version` command displays the current version of conda. + * Output format: "conda X.Y.Z" where X.Y.Z is the semantic version. + */ +export class CondaVersionCommand extends VersionCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const args = this.buildCommand(); + const output = await runCondaExecutable(args, this.log, executeArgs?.cancellationToken); + + // "conda X.Y.Z" + const match = output.match(/conda\s+(\d+\.\d+(?:\.\d+)*)/i); + return match ? match[1] : ''; + } +} diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 4105800b..27065873 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,8 +1,7 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { explain as parse, rcompare } from '@renovatebot/pep440'; +import { explain as parse } from '@renovatebot/pep440'; import * as path from 'path'; import { - CancellationError, Disposable, Event, EventEmitter, @@ -12,6 +11,7 @@ import { RelativePattern, } from 'vscode'; import { + CommandConstructorOptions, DidChangePackagesEventArgs, GetPackagesOptions, IconPath, @@ -23,10 +23,17 @@ import { } from '../../api'; import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { CondaStrings } from '../../common/localize'; -import { traceError } from '../../common/logging'; import { withProgress } from '../../common/window.apis'; +import { parsePackageSpecs } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; -import { getCommonCondaPackagesToInstall, managePackages, runCondaExecutable } from './condaUtils'; +import { + CondaAvailableVersionsCommand, + CondaInstallCommand, + CondaListCommand, + CondaUninstallCommand, + CondaVersionCommand, +} from './commands/index'; +import { getCommonCondaPackagesToInstall } from './condaUtils'; export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -63,40 +70,42 @@ export class CondaPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; - await withProgress( - { - location: ProgressLocation.Notification, - title: CondaStrings.condaInstallingPackages, - cancellable: true, - }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - - this.log.error('Error installing packages', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); - } - }, - ); + try { + // Centralize command options for install/uninstall operations + const manageCommandOptions: CommandConstructorOptions = { + pythonExecutable: 'conda', + log: this.log, + }; + + // Execute uninstall if needed + if (toUninstall.length > 0) { + const uninstallCmd = new CondaUninstallCommand(manageCommandOptions); + const packages = parsePackageSpecs(toUninstall); + await uninstallCmd.executeWithProgress( + { packages, showProgress: true }, + CondaStrings.condaInstallingPackages, + ); + } + + // Execute install if needed + if (toInstall.length > 0) { + const installCmd = new CondaInstallCommand(manageCommandOptions); + const packages = parsePackageSpecs(toInstall); + await installCmd.executeWithProgress( + { packages, upgrade: options.upgrade, showProgress: true }, + CondaStrings.condaInstallingPackages, + ); + } + + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + this.log.error('Error installing packages', e); + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } } async refresh(environment: PythonEnvironment): Promise { @@ -120,34 +129,12 @@ export class CondaPackageManager implements PackageManager, Disposable { async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const args = ['list', '-p', environment.environmentPath.fsPath, '--json']; - const data = await runCondaExecutable(args); - - let condaPackages: { name: string; version: string }[]; - try { - condaPackages = JSON.parse(data) as { name: string; version: string }[]; - } catch (e) { - traceError(`Failed to parse conda list JSON output: ${data}`, e); - return []; - } - - const packages: Package[] = []; - for (const condaPkg of condaPackages) { - if (condaPkg.name && condaPkg.version) { - packages.push( - this.api.createPackageItem( - { - name: condaPkg.name, - displayName: condaPkg.name, - version: condaPkg.version, - description: condaPkg.version, - }, - environment, - this, - ), - ); - } - } + const listCmd = new CondaListCommand({ + pythonExecutable: 'conda', + log: this.log, + }); + const data = await listCmd.execute(); + const packages = (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); this.packages.set(environment.envId.id, packages); return packages; } @@ -161,10 +148,12 @@ export class CondaPackageManager implements PackageManager, Disposable { async getVersion(_environment: PythonEnvironment): Promise { try { - const output = await runCondaExecutable(['--version'], this.log); - // "conda X.Y.Z" - const match = output.match(/conda\s+(\d+\.\d+(?:\.\d+)*)/i); - return match ? (parse(match[1]) ?? undefined) : undefined; + const versionCmd = new CondaVersionCommand({ + pythonExecutable: 'conda', + log: this.log, + }); + const versionString = await versionCmd.execute(); + return versionString ? (parse(versionString) ?? undefined) : undefined; } catch { return undefined; } @@ -175,25 +164,12 @@ export class CondaPackageManager implements PackageManager, Disposable { packageName: string, ): Promise { try { - const output = await runCondaExecutable(['search', packageName, '--json'], this.log); - const parsed = JSON.parse(output); - if (parsed && typeof parsed === 'object' && Array.isArray(parsed[packageName])) { - const uniqueVersions = new Map(); - parsed[packageName] - .filter((entry: { version?: string }) => !!entry.version?.trim()) - .map((entry: { version?: string }) => parse(entry.version!)) - .filter((v: Pep440Version | null): v is Pep440Version => v !== null) - .forEach((version: Pep440Version) => { - if (!uniqueVersions.has(version.public)) { - uniqueVersions.set(version.public, version); - } - }); - - return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => - rcompare(a.public, b.public), - ); - } - return undefined; + const availableVersionsCmd = new CondaAvailableVersionsCommand({ + pythonExecutable: 'conda', + log: this.log, + }); + const versionStrings = await availableVersionsCmd.execute({ packageName, pythonVersion: '' }); + return versionStrings.map((v) => parse(v)).filter((parsed) => parsed !== undefined) as Pep440Version[]; } catch { return undefined; } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 49dffd63..61040e99 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1275,29 +1275,6 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log ); } -export async function managePackages( - environment: PythonEnvironment, - options: PackageManagementOptions, - token: CancellationToken, - log: LogOutputChannel, -): Promise { - if (options.uninstall && options.uninstall.length > 0) { - await runCondaExecutable( - ['remove', '--prefix', environment.environmentPath.fsPath, '--yes', ...options.uninstall], - log, - token, - ); - } - if (options.install && options.install.length > 0) { - const args = ['install', '--prefix', environment.environmentPath.fsPath, '--yes']; - if (options.upgrade) { - args.push('--update-all'); - } - args.push(...options.install); - await runCondaExecutable(args, log, token); - } -} - async function getCommonPackages(): Promise { try { const pipData = path.join(EXTENSION_ROOT_DIR, 'files', 'conda_packages.json'); diff --git a/src/managers/poetry/commands/add.ts b/src/managers/poetry/commands/add.ts new file mode 100644 index 00000000..501b710d --- /dev/null +++ b/src/managers/poetry/commands/add.ts @@ -0,0 +1,41 @@ +import { CommandConstructorOptions, InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from '../poetryPackageManager'; + +/** + * Poetry add command. + * + * Parsed Command: `poetry add [--allow-prereleases] [ ...]` + * + * Official Documentation: https://python-poetry.org/docs/cli/#add + * The `poetry add` command adds required packages to your pyproject.toml and installs them. + * It's the primary way to add dependencies to a Poetry project. + */ +export class PoetryAddCommand extends InstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + const args = ['add']; + + if (executeArgs.upgrade) { + args.push('--allow-prereleases'); + } + + args.push( + ...executeArgs.packages.map((pkg) => { + if (pkg.version) { + return `${pkg.packageName}@${pkg.version}`; + } + return pkg.packageName; + }), + ); + + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + await runPoetry(args, undefined, this.log, executeArgs.cancellationToken); + } +} diff --git a/src/managers/poetry/commands/index.ts b/src/managers/poetry/commands/index.ts new file mode 100644 index 00000000..19a89134 --- /dev/null +++ b/src/managers/poetry/commands/index.ts @@ -0,0 +1,5 @@ +export { PoetryAddCommand } from './add'; +export { PoetryRemoveCommand } from './remove'; +export { PoetryShowCommand } from './show'; +export { PoetryShowTopLevelCommand } from './showTopLevel'; +export { PoetryVersionCommand } from './version'; diff --git a/src/managers/poetry/commands/remove.ts b/src/managers/poetry/commands/remove.ts new file mode 100644 index 00000000..da31c590 --- /dev/null +++ b/src/managers/poetry/commands/remove.ts @@ -0,0 +1,26 @@ +import { CommandConstructorOptions, UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from '../poetryPackageManager'; + +/** + * Poetry remove command. + * + * Parsed Command: `poetry remove [ ...]` + * + * Official Documentation: https://python-poetry.org/docs/cli/#remove + * The `poetry remove` command removes packages from your pyproject.toml and uninstalls them + * from your virtual environment. It removes both the dependency declaration and the installed package. + */ +export class PoetryRemoveCommand extends UninstallCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + return ['remove', ...executeArgs.packages.map((pkg) => pkg.packageName)]; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + const args = this.buildCommand(executeArgs); + await runPoetry(args, undefined, this.log, executeArgs.cancellationToken); + } +} diff --git a/src/managers/poetry/commands/show.ts b/src/managers/poetry/commands/show.ts new file mode 100644 index 00000000..3832b40e --- /dev/null +++ b/src/managers/poetry/commands/show.ts @@ -0,0 +1,53 @@ +import { PackageInfo } from '../../../api'; +import { CommandConstructorOptions, ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from '../poetryPackageManager'; + +/** + * Poetry show command. + * + * Parsed Command: `poetry show --no-ansi` + * + * Official Documentation: https://python-poetry.org/docs/cli/#show + * The `poetry show` command displays information about the installed packages. + * The `--no-ansi` flag disables ANSI color output for easier parsing. + */ +export class PoetryShowCommand extends ListCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['show', '--no-ansi']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const args = this.buildCommand(); + const output = await runPoetry(args, undefined, this.log, executeArgs?.cancellationToken); + + const packages: PackageInfo[] = []; + + try { + // Parse poetry show output + // Format: name version description + const lines = output.split('\n'); + for (const line of lines) { + // Updated regex to properly handle lines with the format: + // "package (!) version description" + const match = line.match(/^(\S+)(?:\s+\([!]\))?\s+(\S+)\s+(.*)/); + if (match) { + const [, name, version, description] = match; + packages.push({ + name, + displayName: name, + version, + description: `${version} - ${description?.trim() || ''}`, + }); + } + } + } catch { + return []; + } + + return packages; + } +} diff --git a/src/managers/poetry/commands/showTopLevel.ts b/src/managers/poetry/commands/showTopLevel.ts new file mode 100644 index 00000000..ff6694de --- /dev/null +++ b/src/managers/poetry/commands/showTopLevel.ts @@ -0,0 +1,38 @@ +import { CommandConstructorOptions, ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from '../poetryPackageManager'; + +/** + * Poetry show --top-level command. + * + * Parsed Command: `poetry show --no-ansi --top-level` + * + * Official Documentation: https://python-poetry.org/docs/cli/#show + * The `poetry show` command with `--top-level` flag displays only the top-level (directly installed) + * packages. The `--no-ansi` flag disables ANSI color output for easier parsing. + * This is useful for determining which packages were explicitly installed vs. dependencies. + */ +export class PoetryShowTopLevelCommand extends ListDirectNamesCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['show', '--no-ansi', '--top-level']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const args = this.buildCommand(); + const output = await runPoetry(args, undefined, this.log, executeArgs?.cancellationToken); + + try { + const names = output + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(/^([a-zA-Z0-9._-]+)/)?.[1] ?? '') + .filter((name) => !!name); + return names; + } catch { + return []; + } + } +} diff --git a/src/managers/poetry/commands/version.ts b/src/managers/poetry/commands/version.ts new file mode 100644 index 00000000..772fbb8e --- /dev/null +++ b/src/managers/poetry/commands/version.ts @@ -0,0 +1,32 @@ +import { CommandConstructorOptions, VersionCommand } from '../../base/commands/index'; +import { getPoetryVersion } from '../poetryUtils'; + +/** + * Poetry version command. + * + * Parsed Command: `poetry --version` + * + * Official Documentation: https://python-poetry.org/docs/cli/#options + * The `--version` option displays the current version of Poetry. + * Returns output in format: "Poetry (version X.Y.Z)" which is parsed to extract the version string. + */ +export class PoetryVersionCommand extends VersionCommand { + constructor(options: CommandConstructorOptions) { + super(options); + } + + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(): Promise { + try { + // Poetry version is obtained via getPoetryVersion utility which handles poetry --version + // We pass the pythonExecutable as the poetry path since it was set to the poetry executable + const versionString = await getPoetryVersion(this.pythonExecutable); + return versionString || ''; + } catch { + return ''; + } + } +} diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 54845a20..637ba0df 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -1,7 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { explain as parse } from '@renovatebot/pep440'; -import * as fsapi from 'fs-extra'; -import * as path from 'path'; import { CancellationError, CancellationToken, @@ -26,10 +24,17 @@ import { } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; -import { normalizePackageName } from '../builtin/utils'; +import { normalizePackageName, parsePackageSpecs } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; +import { + PoetryAddCommand, + PoetryRemoveCommand, + PoetryShowCommand, + PoetryShowTopLevelCommand, + PoetryVersionCommand, +} from './commands/index'; import { PoetryManager } from './poetryManager'; -import { getPoetry, getPoetryVersion } from './poetryUtils'; +import { getPoetry } from './poetryUtils'; export class PoetryPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -77,38 +82,21 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - await withProgress( - { - location: ProgressLocation.Notification, - title: 'Managing packages with Poetry', - cancellable: true, - }, - async (_progress, token) => { - try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages with Poetry', e); - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - throw e; + try { + await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + this.log.error('Error managing packages with Poetry', e); + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); } - }, - ); + }); + throw e; + } } async refresh(environment: PythonEnvironment): Promise { @@ -156,8 +144,12 @@ export class PoetryPackageManager implements PackageManager, Disposable { if (!poetry) { return undefined; } - const versionStr = await getPoetryVersion(poetry); - return versionStr ? (parse(versionStr) ?? undefined) : undefined; + const versionCmd = new PoetryVersionCommand({ + pythonExecutable: poetry, + log: this.log, + }); + const versionString = await versionCmd.execute(); + return versionString ? (parse(versionString) ?? undefined) : undefined; } async getPackageAvailableVersions( @@ -180,10 +172,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { this.packages.clear(); } - private async runPoetryManage( - options: { install?: string[]; uninstall?: string[] }, - token?: CancellationToken, - ): Promise { + private async runPoetryManage(options: { install?: string[]; uninstall?: string[] }): Promise { const poetry = await getPoetry(); if (!poetry) { throw new Error( @@ -195,28 +184,22 @@ export class PoetryPackageManager implements PackageManager, Disposable { // Handle uninstalls first if (options.uninstall && options.uninstall.length > 0) { - try { - const args = ['remove', ...options.uninstall]; - this.log.info(`Running: poetry ${args.join(' ')}`); - const result = await runPoetry(args, undefined, this.log, token); - this.log.info(result); - } catch (err) { - this.log.error(`Error removing packages with Poetry: ${err}`); - throw err; - } + const removeCmd = new PoetryRemoveCommand({ + pythonExecutable: poetry, + log: this.log, + }); + const packages = parsePackageSpecs(options.uninstall); + await removeCmd.executeWithProgress({ packages, showProgress: true }, 'Managing packages with Poetry'); } // Handle installs if (options.install && options.install.length > 0) { - try { - const args = ['add', ...options.install]; - this.log.info(`Running: poetry ${args.join(' ')}`); - const result = await runPoetry(args, undefined, this.log, token); - this.log.info(result); - } catch (err) { - this.log.error(`Error adding packages with Poetry: ${err}`); - throw err; - } + const addCmd = new PoetryAddCommand({ + pythonExecutable: poetry, + log: this.log, + }); + const packages = parsePackageSpecs(options.install); + await addCmd.executeWithProgress({ packages, showProgress: true }, 'Managing packages with Poetry'); } } @@ -230,78 +213,26 @@ export class PoetryPackageManager implements PackageManager, Disposable { ); } - let cwd = process.cwd(); - const projects = this.api.getPythonProjects(); - if (projects.length === 1) { - const stat = await fsapi.stat(projects[0].uri.fsPath); - if (stat.isDirectory()) { - cwd = projects[0].uri.fsPath; - } else { - cwd = path.dirname(projects[0].uri.fsPath); - } - } else if (projects.length > 1) { - const dirs = new Set(); - await Promise.all( - projects.map(async (project) => { - const e = await this.api.getEnvironment(project.uri); - if (e?.envId.id === environment.envId.id) { - const stat = await fsapi.stat(projects[0].uri.fsPath); - const dir = stat.isDirectory() ? projects[0].uri.fsPath : path.dirname(projects[0].uri.fsPath); - if (dirs.has(dir)) { - dirs.add(dir); - } - } - }), - ); - if (dirs.size > 0) { - // ensure we have the deepest directory node picked - cwd = Array.from(dirs.values()).sort((a, b) => (a.length - b.length) * -1)[0]; - } - } - - const poetryPackages: { name: string; version: string; displayName: string; description: string }[] = []; - - try { - this.log.info(`Running: ${await getPoetry()} show --no-ansi`); - const result = await runPoetry(['show', '--no-ansi'], cwd, this.log); - - // Parse poetry show output - // Format: name version description - const lines = result.split('\n'); - for (const line of lines) { - // Updated regex to properly handle lines with the format: - // "package (!) version description" - const match = line.match(/^(\S+)(?:\s+\([!]\))?\s+(\S+)\s+(.*)/); - if (match) { - const [, name, version, description] = match; - poetryPackages.push({ - name, - version, - displayName: name, - description: `${version} - ${description?.trim() || ''}`, - }); - } - } - } catch (err) { - this.log.error(`Error refreshing packages with Poetry: ${err}`); - // Return empty array instead of throwing to avoid breaking the UI - return []; - } - - // Convert to Package objects using the API - return poetryPackages.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + const showCmd = new PoetryShowCommand({ + pythonExecutable: poetry, + log: this.log, + }); + const data = await showCmd.execute(); + return (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); } async getDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { try { - const topLevelResult = await runPoetry(['show', '--no-ansi', '--top-level'], undefined, this.log); - const names = topLevelResult - .split('\n') - .map((line) => line.trim()) - .map((line) => line.match(/^([a-zA-Z0-9._-]+)/)?.[1] ?? '') - .filter((name) => !!name) - .map(normalizePackageName); - return new Set(names); + const poetry = await getPoetry(); + if (!poetry) { + return undefined; + } + const showTopLevelCmd = new PoetryShowTopLevelCommand({ + pythonExecutable: poetry, + log: this.log, + }); + const names = await showTopLevelCmd.execute(); + return names ? new Set(names.map(normalizePackageName)) : undefined; } catch (err) { this.log.error(`Error fetching direct package names with Poetry: ${err}`); return undefined; diff --git a/src/test/managers/builtin/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts deleted file mode 100644 index 5c06c394..00000000 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'assert'; -import { explain } from '@renovatebot/pep440'; -import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipPackageManager'; - -suite('Pip Version Parsing', () => { - suite('parsePipIndexVersionsJson', () => { - test('parses valid JSON with versions array', () => { - const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); - const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); - }); - - test('parses output with a single version', () => { - const output = JSON.stringify({ name: 'my-package', versions: ['1.0.0'] }); - const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, [explain('1.0.0')]); - }); - - test('returns undefined for empty versions array', () => { - const output = JSON.stringify({ name: 'pkg', versions: [] }); - const versions = parsePipIndexVersionsJson(output); - assert.strictEqual(versions, undefined); - }); - - test('returns undefined for invalid JSON', () => { - const versions = parsePipIndexVersionsJson('not json'); - assert.strictEqual(versions, undefined); - }); - - test('returns undefined when versions field is missing', () => { - const output = JSON.stringify({ name: 'pkg' }); - const versions = parsePipIndexVersionsJson(output); - assert.strictEqual(versions, undefined); - }); - }); -}); -