diff --git a/src/managers/base/commands/availableVersions.ts b/src/managers/base/commands/availableVersions.ts new file mode 100644 index 00000000..166141c0 --- /dev/null +++ b/src/managers/base/commands/availableVersions.ts @@ -0,0 +1,31 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { BaseExecuteArgs, 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 { + protected static readonly configSection = 'availableVersionsCommandArgs'; + protected abstract buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[]; + protected parseVersions(versions: string[], includePrerelease?: boolean): Pep440Version[] { + let parsed = Array.from(new Set(versions)) + .map((version) => parsePep440Version(version.trim())) + .filter((version): version is Pep440Version => version !== null); + if (includePrerelease === false) { + parsed = parsed.filter((version) => !version.is_prerelease); + } + return parsed; + } + 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..fc863723 --- /dev/null +++ b/src/managers/base/commands/install.ts @@ -0,0 +1,19 @@ +import { BaseExecuteArgs, 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 { + protected static readonly 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..9d3f6940 --- /dev/null +++ b/src/managers/base/commands/list.ts @@ -0,0 +1,12 @@ +import { PackageInfo } from '../../../api'; +import { BaseExecuteArgs, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for list commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class ListCommand extends PackageManagerCommand { + protected static readonly configSection = 'listCommandArgs'; + protected timeout = 30000; + 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..5e931031 --- /dev/null +++ b/src/managers/base/commands/listDirectNames.ts @@ -0,0 +1,11 @@ +import { BaseExecuteArgs, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for listDirectNames commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class ListDirectNamesCommand extends PackageManagerCommand { + protected static readonly configSection = 'listDirectNamesCommandArgs'; + protected timeout = 30000; + 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..0888ae01 --- /dev/null +++ b/src/managers/base/commands/packageManagerCommand.ts @@ -0,0 +1,46 @@ +import { CancellationToken, LogOutputChannel, 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; +} + +/** + * Constructor options shared by all package manager commands. + */ +export interface CommandConstructorOptions { + pythonExecutable: string; + cwd?: string; + log?: LogOutputChannel; +} + +/** + * Base class for all package manager commands. + * Provides common properties and minimal interface for subclasses. + */ +export abstract class PackageManagerCommand { + protected static readonly configSection?: string; + + protected pythonExecutable: string; + protected cwd?: string; + protected log?: LogOutputChannel; + protected timeout: number = 300000; + protected config?: WorkspaceConfiguration; + + constructor(options: CommandConstructorOptions) { + this.pythonExecutable = options.pythonExecutable; + this.cwd = options.cwd; + this.log = options.log; + const configSection = (this.constructor as typeof PackageManagerCommand).configSection; + this.config = configSection ? getConfiguration(`python-envs.packageManager.${configSection}`) : undefined; + } + + /** + * 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..5124c3b0 --- /dev/null +++ b/src/managers/base/commands/uninstall.ts @@ -0,0 +1,18 @@ +import { BaseExecuteArgs, 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 { + protected static readonly 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..9e917e0a --- /dev/null +++ b/src/managers/base/commands/version.ts @@ -0,0 +1,11 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { BaseExecuteArgs, PackageManagerCommand } from './packageManagerCommand'; + +/** + * Template class for version commands. + * Subclasses implement concrete package-manager-specific logic. + */ +export abstract class VersionCommand extends PackageManagerCommand { + protected static readonly 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..fb8128fe --- /dev/null +++ b/src/managers/builtin/commands/availableVersions.ts @@ -0,0 +1,93 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { AvailableVersionsCommand, type AvailableVersionsExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } 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/ + */ +export class PipAvailableVersionsCommand extends AvailableVersionsCommand { + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + return [ + '-m', + 'pip', + 'index', + 'versions', + executeArgs.packageName, + '--json', + '--python-version', + executeArgs.pythonVersion, + ]; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + const output = await runPython( + this.pythonExecutable, + this.buildCommand(executeArgs), + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout, + ); + const match = output.match(/{[\s\S]*}/); + if (!match) { + return []; + } + + try { + const parsed = JSON.parse(match[0]) as { versions?: string[] }; + return this.parseVersions( + Array.isArray(parsed.versions) ? parsed.versions : [], + executeArgs.includePrerelease, + ); + } catch { + return []; + } + } +} + +/** + * UV available versions command. + * Parsed command: `uv tool run pip index versions --json --python-version ` + * Official documentation: https://docs.astral.sh/uv/pip/ + */ +export class UvAvailableVersionsCommand extends AvailableVersionsCommand { + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + return [ + 'tool', + 'run', + 'pip', + 'index', + 'versions', + executeArgs.packageName, + '--json', + '--python-version', + executeArgs.pythonVersion, + ]; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + const output = await runUV( + this.buildCommand(executeArgs), + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout, + ); + const match = output.match(/{[\s\S]*}/); + if (!match) { + return []; + } + + try { + const parsed = JSON.parse(match[0]) as { versions?: string[] }; + return this.parseVersions( + Array.isArray(parsed.versions) ? parsed.versions : [], + executeArgs.includePrerelease, + ); + } catch { + return []; + } + } +} diff --git a/src/managers/builtin/commands/factory.ts b/src/managers/builtin/commands/factory.ts new file mode 100644 index 00000000..0bb76d53 --- /dev/null +++ b/src/managers/builtin/commands/factory.ts @@ -0,0 +1,13 @@ +import { CommandConstructorOptions } from '../../base/commands/index'; +import { shouldUseUv } from '../helpers'; + +type CommandConstructor = new (options: CommandConstructorOptions) => T; + +export async function createPipOrUvCommand( + options: CommandConstructorOptions, + environmentPath: string, + PipCommand: CommandConstructor

, + UvCommand: CommandConstructor, +): Promise { + return (await shouldUseUv(options.log, environmentPath)) ? new UvCommand(options) : new PipCommand(options); +} 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..6f484287 --- /dev/null +++ b/src/managers/builtin/commands/install.ts @@ -0,0 +1,52 @@ +import { InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } from '../helpers'; +import { processEditableInstallArgs } from '../utils'; + +/** + * Pip install command. + * Parsed command: `python -m pip install [--upgrade] ` + * Official documentation: https://pip.pypa.io/en/stable/cli/pip_install/ + */ +export class PipInstallCommand extends InstallCommand { + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + let args = ['-m', 'pip', 'install']; + 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 { + await runPython( + this.pythonExecutable, + this.buildCommand(executeArgs), + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout, + ); + } +} + +/** + * UV install command. + * Parsed command: `uv pip install --python [--upgrade] ` + * Official documentation: https://docs.astral.sh/uv/pip/ + */ +export class UvInstallCommand extends InstallCommand { + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + let args = ['pip', 'install', '--python', this.pythonExecutable]; + 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 { + await runUV(this.buildCommand(executeArgs), 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..1e129a63 --- /dev/null +++ b/src/managers/builtin/commands/list.ts @@ -0,0 +1,86 @@ +import { PackageInfo } from '../../../api'; +import { ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } from '../helpers'; + +/** + * Pip list command. + * Parsed command: `python -m pip list --format=json` --disable-pip-version-check + * Official documentation: https://pip.pypa.io/en/stable/cli/pip_list/ + */ +export class PipListCommand extends ListCommand { + protected buildCommand(): string[] { + return ['-m', 'pip', 'list', '--format=json', '--disable-pip-version-check']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runPython( + this.pythonExecutable, + this.buildCommand(), + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + let json: unknown; + try { + json = JSON.parse(output); + } catch (e) { + this.log?.error(`Failed to parse pip list output: ${e}`); + return []; + } + if (!Array.isArray(json)) { + this.log?.error('Invalid output from pip list command'); + return []; + } + + return json + .filter(({ name, version }) => !!name && !!version) + .map(({ name, version }) => ({ + name, + version, + displayName: name, + description: version, + })); + } +} + +/** + * UV list command. + * Parsed command: `uv pip list --format=json --python ` + * Official documentation: https://docs.astral.sh/uv/pip/ + */ +export class UvListCommand extends ListCommand { + protected buildCommand(): string[] { + return ['pip', 'list', '--format=json', '--python', this.pythonExecutable]; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runUV( + this.buildCommand(), + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + let json: unknown; + try { + json = JSON.parse(output); + } catch (e) { + this.log?.error(`Failed to parse uv pip list output: ${e}`); + return []; + } + if (!Array.isArray(json)) { + this.log?.error('Invalid output from uv pip list command'); + return []; + } + + return json + .filter(({ name, version }) => !!name && !!version) + .map(({ name, version }) => ({ + name, + version, + displayName: name, + description: version, + })); + } +} diff --git a/src/managers/builtin/commands/listDirectNames.ts b/src/managers/builtin/commands/listDirectNames.ts new file mode 100644 index 00000000..cdeb109e --- /dev/null +++ b/src/managers/builtin/commands/listDirectNames.ts @@ -0,0 +1,78 @@ +import { ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } from '../helpers'; +import { normalizePackageName } from '../utils'; + +/** + * 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/ + */ +export class PipListDirectNamesCommand extends ListDirectNamesCommand { + protected buildCommand(): string[] { + return ['-m', 'pip', 'list', '--format=json', '--not-required']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise> { + const output = await runPython( + this.pythonExecutable, + this.buildCommand(), + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + let packages: unknown; + try { + packages = JSON.parse(output); + } catch (e) { + this.log?.error(`Failed to parse pip list output: ${e}`); + return new Set(); + } + if (!Array.isArray(packages)) { + this.log?.error('Invalid output from pip list command'); + return new Set(); + } + + return new Set(packages.filter(({ name }) => !!name).map(({ name }) => normalizePackageName(name))); + } +} + +/** + * UV list direct names command. + * Parsed command: `uv pip tree --python --depth=0` + * Official documentation: https://docs.astral.sh/uv/pip/ + */ +export class UvListDirectNamesCommand extends ListDirectNamesCommand { + protected buildCommand(): string[] { + return ['pip', 'tree', '--python', this.pythonExecutable, '--depth=0']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise> { + const output = await runUV( + this.buildCommand(), + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + const packageNames = new Set(); + const lines = output.split('\n'); + + for (const line of lines) { + // Tree output has top-level packages at the start of the line with no indentation + // Dependencies are indented with tree characters (├, └, │, etc.) + // We only want lines that start with a package name (not whitespace or tree chars) + if (line.length === 0 || /^[\s├└│]/.test(line)) { + continue; + } + + // Extract package name (first word, before space or end of line) + const match = line.match(/^(\S+)/); + if (match && match[1]) { + packageNames.add(normalizePackageName(match[1])); + } + } + + return packageNames; + } +} diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts new file mode 100644 index 00000000..50ffe61e --- /dev/null +++ b/src/managers/builtin/commands/uninstall.ts @@ -0,0 +1,41 @@ +import { UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } from '../helpers'; + +/** + * Pip uninstall command. + * Parsed command: `python -m pip uninstall -y ` + * Official documentation: https://pip.pypa.io/en/stable/cli/pip_uninstall/ + */ +export class PipUninstallCommand extends UninstallCommand { + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + return ['-m', 'pip', 'uninstall', '-y', ...executeArgs.packages.map((pkg) => pkg.packageName)]; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + await runPython( + this.pythonExecutable, + this.buildCommand(executeArgs), + 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/ + */ +export class UvUninstallCommand extends UninstallCommand { + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + const args = ['pip', 'uninstall', '--python', this.pythonExecutable]; + args.push(...executeArgs.packages.map((pkg) => pkg.packageName)); + return args; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + await runUV(this.buildCommand(executeArgs), 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..77faa3a0 --- /dev/null +++ b/src/managers/builtin/commands/version.ts @@ -0,0 +1,47 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPython, runUV } from '../helpers'; + +/** + * Pip version command. + * Parsed command: `python -m pip --version` + * Official documentation: https://pip.pypa.io/en/stable/cli/pip/ + */ +export class PipVersionCommand extends VersionCommand { + protected buildCommand(): string[] { + return ['-m', 'pip', '--version']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runPython( + this.pythonExecutable, + this.buildCommand(), + undefined, + this.log, + executeArgs?.cancellationToken, + this.timeout, + ); + + const match = output.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); + return match ? (parsePep440Version(match[1]) ?? undefined) : undefined; + } +} + +/** + * UV version command. + * Parsed command: `uv --version` + * Official documentation: https://docs.astral.sh/uv/ + */ +export class UvVersionCommand extends VersionCommand { + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(): Promise { + const output = await runUV(this.buildCommand(), undefined, this.log, undefined, this.timeout); + + const match = output.match(/(\d+\.\d+(?:\.\d+)*)/); + return match ? (parsePep440Version(match[1]) ?? undefined) : undefined; + } +} diff --git a/src/managers/conda/commands/availableVersions.ts b/src/managers/conda/commands/availableVersions.ts new file mode 100644 index 00000000..629d6b9d --- /dev/null +++ b/src/managers/conda/commands/availableVersions.ts @@ -0,0 +1,35 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { AvailableVersionsCommand, type AvailableVersionsExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda available versions command. + * Parsed command: `conda search --json` + * Official documentation: https://docs.conda.io/projects/conda/en/latest/commands/search.html + */ +export class CondaAvailableVersionsCommand extends AvailableVersionsCommand { + protected buildCommand(executeArgs: AvailableVersionsExecuteArgs): string[] { + return ['search', executeArgs.packageName, '--json']; + } + + async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + const output = await runCondaExecutable( + this.buildCommand(executeArgs), + this.log, + executeArgs.cancellationToken, + ); + + try { + const parsed = JSON.parse(output); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed[executeArgs.packageName])) { + const versions = (parsed[executeArgs.packageName] as Array<{ version?: string }>) + .map((entry) => entry.version?.trim() ?? '') + .filter((version) => !!version); + return this.parseVersions(versions, executeArgs.includePrerelease); + } + return []; + } catch { + return []; + } + } +} diff --git a/src/managers/conda/commands/condaCommandOptions.ts b/src/managers/conda/commands/condaCommandOptions.ts new file mode 100644 index 00000000..a094aaa7 --- /dev/null +++ b/src/managers/conda/commands/condaCommandOptions.ts @@ -0,0 +1,5 @@ +import { CommandConstructorOptions } from '../../base/commands/index'; + +export interface CondaCommandConstructorOptions extends CommandConstructorOptions { + condaEnvironmentPath: string; +} diff --git a/src/managers/conda/commands/index.ts b/src/managers/conda/commands/index.ts new file mode 100644 index 00000000..7633b1ae --- /dev/null +++ b/src/managers/conda/commands/index.ts @@ -0,0 +1,6 @@ +export { CondaAvailableVersionsCommand } from './availableVersions'; +export { CondaCommandConstructorOptions } from './condaCommandOptions'; +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..ad3e0469 --- /dev/null +++ b/src/managers/conda/commands/install.ts @@ -0,0 +1,38 @@ +import { InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; +import { CondaCommandConstructorOptions } from './condaCommandOptions'; + +/** + * Conda install command. + * Parsed command: `conda install --prefix --yes ` + * Parsed command (upgrade): `conda install --prefix --yes --update-all ` + * Official documentation: https://docs.conda.io/projects/conda/en/latest/commands/install.html + */ +export class CondaInstallCommand extends InstallCommand { + private readonly condaEnvironmentPath: string; + + constructor(options: CondaCommandConstructorOptions) { + super(options); + this.condaEnvironmentPath = options.condaEnvironmentPath; + } + + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + const args = ['install', '--prefix', this.condaEnvironmentPath, '--yes']; + if (executeArgs.upgrade) { + args.push('--update-all'); + } + args.push( + ...executeArgs.packages.map((pkg) => { + if (pkg.version) { + return `${pkg.packageName}=${pkg.version}`; + } + return pkg.packageName; + }), + ); + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + await runCondaExecutable(this.buildCommand(executeArgs), 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..aeb989bc --- /dev/null +++ b/src/managers/conda/commands/list.ts @@ -0,0 +1,46 @@ +import { PackageInfo } from '../../../api'; +import { ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; +import { CondaCommandConstructorOptions } from './condaCommandOptions'; + +/** + * Conda list command. + * Parsed command: `conda list -p --json` + * Official documentation: https://docs.conda.io/projects/conda/en/latest/commands/list.html + */ +export class CondaListCommand extends ListCommand { + private readonly condaEnvironmentPath: string; + + constructor(options: CondaCommandConstructorOptions) { + super(options); + this.condaEnvironmentPath = options.condaEnvironmentPath; + } + + protected buildCommand(): string[] { + return ['list', '-p', this.condaEnvironmentPath, '--json']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runCondaExecutable(this.buildCommand(), 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..53400acf --- /dev/null +++ b/src/managers/conda/commands/uninstall.ts @@ -0,0 +1,27 @@ +import { UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; +import { CondaCommandConstructorOptions } from './condaCommandOptions'; + +/** + * Conda uninstall command. + * Parsed command: `conda remove -y -p ` + * Official documentation: https://docs.conda.io/projects/conda/en/latest/commands/remove.html + */ +export class CondaUninstallCommand extends UninstallCommand { + private readonly condaEnvironmentPath: string; + + constructor(options: CondaCommandConstructorOptions) { + super(options); + this.condaEnvironmentPath = options.condaEnvironmentPath; + } + + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + const args = ['remove', '-y', '-p', this.condaEnvironmentPath]; + args.push(...executeArgs.packages.map((pkg) => pkg.packageName)); + return args; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + await runCondaExecutable(this.buildCommand(executeArgs), 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..e9921b2c --- /dev/null +++ b/src/managers/conda/commands/version.ts @@ -0,0 +1,23 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runCondaExecutable } from '../condaUtils'; + +/** + * Conda version command. + * Parsed command: `conda --version` + * Official documentation: https://docs.conda.io/projects/conda/en/latest/commands.html + */ +export class CondaVersionCommand extends VersionCommand { + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runCondaExecutable(this.buildCommand(), this.log, executeArgs?.cancellationToken); + + // "conda X.Y.Z" + const match = output.match(/conda\s+(\d+\.\d+(?:\.\d+)*)/i); + return match ? (parsePep440Version(match[1]) ?? undefined) : undefined; + } +} diff --git a/src/managers/poetry/commands/add.ts b/src/managers/poetry/commands/add.ts new file mode 100644 index 00000000..972c711e --- /dev/null +++ b/src/managers/poetry/commands/add.ts @@ -0,0 +1,27 @@ +import { InstallCommand, type InstallExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from './runPoetry'; + +/** + * Poetry add command. + * Parsed command: `poetry add [ ...]` + * Official documentation: https://python-poetry.org/docs/cli/#add + */ +export class PoetryAddCommand extends InstallCommand { + protected buildCommand(executeArgs: InstallExecuteArgs): string[] { + const args = ['add']; + args.push( + ...executeArgs.packages.map((pkg) => { + if (pkg.version) { + return `${pkg.packageName}@${pkg.version}`; + } + return pkg.packageName; + }), + ); + + return args; + } + + async execute(executeArgs: InstallExecuteArgs): Promise { + await runPoetry(this.buildCommand(executeArgs), this.cwd, 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..6d27e79e --- /dev/null +++ b/src/managers/poetry/commands/remove.ts @@ -0,0 +1,17 @@ +import { UninstallCommand, type UninstallExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from './runPoetry'; + +/** + * Poetry remove command. + * Parsed command: `poetry remove [ ...]` + * Official documentation: https://python-poetry.org/docs/cli/#remove + */ +export class PoetryRemoveCommand extends UninstallCommand { + protected buildCommand(executeArgs: UninstallExecuteArgs): string[] { + return ['remove', ...executeArgs.packages.map((pkg) => pkg.packageName)]; + } + + async execute(executeArgs: UninstallExecuteArgs): Promise { + await runPoetry(this.buildCommand(executeArgs), this.cwd, this.log, executeArgs.cancellationToken); + } +} diff --git a/src/managers/poetry/commands/runPoetry.ts b/src/managers/poetry/commands/runPoetry.ts new file mode 100644 index 00000000..092fe0a7 --- /dev/null +++ b/src/managers/poetry/commands/runPoetry.ts @@ -0,0 +1,47 @@ +import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode'; +import { spawnProcess } from '../../../common/childProcess.apis'; +import { getPoetry } from '../poetryUtils'; + +export async function runPoetry( + args: string[], + cwd?: string, + log?: LogOutputChannel, + token?: CancellationToken, +): Promise { + const poetry = await getPoetry(); + if (!poetry) { + throw new Error('Poetry executable not found'); + } + + log?.info(`Running: ${poetry} ${args.join(' ')}`); + + return new Promise((resolve, reject) => { + const proc = spawnProcess(poetry, args, { cwd }); + token?.onCancellationRequested(() => { + proc.kill(); + reject(new CancellationError()); + }); + let builder = ''; + proc.stdout?.on('data', (data) => { + const output = data.toString('utf-8'); + builder += output; + log?.append(`poetry: ${output}`); + }); + proc.stderr?.on('data', (data) => { + const output = data.toString('utf-8'); + builder += output; + log?.append(`poetry: ${output}`); + }); + proc.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Failed to run poetry ${args.join(' ')}`)); + return; + } + resolve(builder); + }); + proc.on('error', (error) => { + log?.error(`Error executing poetry command: ${error}`); + reject(error); + }); + }); +} diff --git a/src/managers/poetry/commands/show.ts b/src/managers/poetry/commands/show.ts new file mode 100644 index 00000000..9370d974 --- /dev/null +++ b/src/managers/poetry/commands/show.ts @@ -0,0 +1,42 @@ +import { PackageInfo } from '../../../api'; +import { ListCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { runPoetry } from './runPoetry'; + +/** + * Poetry show command. + * Parsed command: `poetry show --no-ansi` + * Official documentation: https://python-poetry.org/docs/cli/#show + */ +export class PoetryShowCommand extends ListCommand { + protected buildCommand(): string[] { + return ['show', '--no-ansi']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise { + const output = await runPoetry(this.buildCommand(), this.cwd, 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..96de5859 --- /dev/null +++ b/src/managers/poetry/commands/showTopLevel.ts @@ -0,0 +1,30 @@ +import { ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { normalizePackageName } from '../../builtin/utils'; +import { runPoetry } from './runPoetry'; + +/** + * Poetry show --top-level command. + * Parsed command: `poetry show --no-ansi --top-level` + * Official documentation: https://python-poetry.org/docs/cli/#show + */ +export class PoetryShowTopLevelCommand extends ListDirectNamesCommand { + protected buildCommand(): string[] { + return ['show', '--no-ansi', '--top-level']; + } + + async execute(executeArgs?: BaseExecuteArgs): Promise> { + const output = await runPoetry(this.buildCommand(), this.cwd, 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) + .map(normalizePackageName); + return new Set(names); + } catch { + return new Set(); + } + } +} diff --git a/src/managers/poetry/commands/version.ts b/src/managers/poetry/commands/version.ts new file mode 100644 index 00000000..d4f343df --- /dev/null +++ b/src/managers/poetry/commands/version.ts @@ -0,0 +1,26 @@ +import type { Pep440Version } from '@renovatebot/pep440'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { getPoetryVersion } from '../poetryUtils'; + +/** + * Poetry version command. + * Parsed command: `poetry --version` + * Official documentation: https://python-poetry.org/docs/cli/#options + */ +export class PoetryVersionCommand extends VersionCommand { + protected buildCommand(): string[] { + return ['--version']; + } + + async execute(_executeArgs?: BaseExecuteArgs): 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 ? (parsePep440Version(versionString) ?? undefined) : undefined; + } catch { + return undefined; + } + } +}