diff --git a/src/common/inlineScript/cacheKey.ts b/src/common/inlineScript/cacheKey.ts index 6030c5f9e..1d4c90486 100644 --- a/src/common/inlineScript/cacheKey.ts +++ b/src/common/inlineScript/cacheKey.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { createHash } from 'crypto'; -import { normalizePackageName } from '../../managers/builtin/utils'; +import { normalizePackageName } from '../../managers/common/packageUtils'; import { normalizePath } from '../utils/pathUtils'; /** Length, in hex chars, of the cache key returned by {@link computeCacheKey}. 16 = 64 bits of SHA-256; fixed-length and filesystem-safe. */ diff --git a/src/managers/builtin/commands/availableVersions.ts b/src/managers/builtin/commands/availableVersions.ts index fb8128fed..021b87991 100644 --- a/src/managers/builtin/commands/availableVersions.ts +++ b/src/managers/builtin/commands/availableVersions.ts @@ -2,26 +2,26 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { AvailableVersionsCommand, type AvailableVersionsExecuteArgs } from '../../base/commands/index'; import { runPython, runUV } from '../helpers'; +export interface PipAvailableVersionsExecuteArgs extends AvailableVersionsExecuteArgs { + useJson?: boolean; +} + /** * Pip available versions command. - * Parsed command: `python -m pip index versions --json --python-version ` + * 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, - ]; + protected buildCommand(executeArgs: PipAvailableVersionsExecuteArgs): string[] { + const args = ['-m', 'pip', 'index', 'versions', executeArgs.packageName]; + if (executeArgs.useJson !== false) { + args.push('--json'); + } + args.push('--python-version', executeArgs.pythonVersion); + return args; } - async execute(executeArgs: AvailableVersionsExecuteArgs): Promise { + async execute(executeArgs: PipAvailableVersionsExecuteArgs): Promise { const output = await runPython( this.pythonExecutable, this.buildCommand(executeArgs), @@ -30,6 +30,11 @@ export class PipAvailableVersionsCommand extends AvailableVersionsCommand { executeArgs.cancellationToken, this.timeout, ); + if (executeArgs.useJson === false) { + const match = output.match(/^Available versions:\s*(.+)$/im); + return this.parseVersions(match?.[1].split(',') ?? [], executeArgs.includePrerelease); + } + const match = output.match(/{[\s\S]*}/); if (!match) { return []; diff --git a/src/managers/builtin/commands/listDirectNames.ts b/src/managers/builtin/commands/listDirectNames.ts index cdeb109ed..841988f52 100644 --- a/src/managers/builtin/commands/listDirectNames.ts +++ b/src/managers/builtin/commands/listDirectNames.ts @@ -1,6 +1,6 @@ import { ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; +import { normalizePackageName } from '../../common/packageUtils'; import { runPython, runUV } from '../helpers'; -import { normalizePackageName } from '../utils'; /** * Pip list direct names command. diff --git a/src/managers/builtin/pipListUtils.ts b/src/managers/builtin/pipListUtils.ts deleted file mode 100644 index 80519d89d..000000000 --- a/src/managers/builtin/pipListUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { LogOutputChannel } from 'vscode'; - -export interface PipPackage { - name: string; - version: string; - displayName: string; - description: string; -} -export function parseUvTree(data: string): string[] { - return data - .split('\n') - .map((line) => line.trim()) - .map((line) => line.split(/\s+/, 1)[0]) - .filter((name) => !!name); -} - -export function parsePipListJson(data: string, log?: LogOutputChannel): PipPackage[] { - try { - const json = JSON.parse(data); - if (Array.isArray(json)) { - return json - .filter((item) => item.name && item.version) - .map(({ name, version }) => ({ - name, - version, - displayName: name, - description: version, - })); - } - } catch (ex) { - log?.error('Failed to parse pip list JSON output', ex); - } - return []; -} diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 836244bb7..4596b7460 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -1,15 +1,15 @@ 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, + l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, - window, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -21,10 +21,26 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { showErrorMessage, withProgress } from '../../common/window.apis'; +import { CommandConstructorOptions } from '../base/commands/index'; import { updatePackagesAndNotify } from '../common/packageChanges'; -import { runPython, runUV, shouldUseUv } from './helpers'; +import { parsePackageSpecs } from '../common/packageUtils'; +import { createPipOrUvCommand } from './commands/factory'; +import { + PipAvailableVersionsCommand, + PipInstallCommand, + PipListCommand, + PipListDirectNamesCommand, + PipUninstallCommand, + PipVersionCommand, + UvAvailableVersionsCommand, + UvInstallCommand, + UvListCommand, + UvListDirectNamesCommand, + UvUninstallCommand, + UvVersionCommand, +} from './commands/index'; import { getWorkspacePackagesToInstall } from './pipUtils'; -import { managePackages, normalizePackageName, refreshPipDirectPackageNames, refreshPipPackages } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -69,20 +85,54 @@ export class PipPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; - await window.withProgress( + if (environment.version.startsWith('2.')) { + throw new Error('Python 2.* is not supported (deprecated)'); + } + + await withProgress( { location: ProgressLocation.Notification, - title: 'Installing packages', + title: l10n.t('Managing packages'), cancellable: true, }, async (_progress, token) => { try { - await managePackages(environment, manageOptions, this, token); + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error('Unable to determine Python executable path'); + } + const commandOptions: CommandConstructorOptions = { + pythonExecutable, + log: this.log, + }; + + if (toUninstall.length > 0) { + const command: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand( + commandOptions, + environment.environmentPath.fsPath, + PipUninstallCommand, + UvUninstallCommand, + ); + await command.execute({ + packages: parsePackageSpecs(toUninstall), + cancellationToken: token, + }); + } + + if (toInstall.length > 0) { + const command: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand( + commandOptions, + environment.environmentPath.fsPath, + PipInstallCommand, + UvInstallCommand, + ); + await command.execute({ + packages: parsePackageSpecs(toInstall), + upgrade: options.upgrade, + cancellationToken: token, + }); + } + await updatePackagesAndNotify( this, environment, @@ -90,16 +140,16 @@ export class PipPackageManager implements PackageManager, Disposable { (changes) => { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, - () => this.fetchPackages(environment, !manageOptions.runHeadless), + () => this.fetchPackages(environment, !options.runHeadless), ); } catch (e) { if (e instanceof CancellationError) { throw e; } this.log.error('Error managing packages', e); - if (!manageOptions.runHeadless) { + if (!options.runHeadless) { setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); + const result = await showErrorMessage('Error managing packages', 'View Output'); if (result === 'View Output') { this.log.show(); } @@ -112,7 +162,7 @@ export class PipPackageManager implements PackageManager, Disposable { } async refresh(environment: PythonEnvironment): Promise { - await window.withProgress( + await withProgress( { location: ProgressLocation.Window, title: 'Refreshing packages', @@ -141,34 +191,48 @@ export class PipPackageManager implements PackageManager, Disposable { } private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise { - const data = await refreshPipPackages(environment, this.log, { showErrors }); - if (data === undefined) { + try { + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error('Unable to determine Python executable path'); + } + const command: PipListCommand | UvListCommand = await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipListCommand, + UvListCommand, + ); + const data = await command.execute(); + const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + this.packages.set(environment.envId.id, packages); + return packages; + } catch (error) { + this.log.error('Error refreshing packages', error); + if (showErrors) { + setImmediate(async () => { + const result = await showErrorMessage('Error refreshing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } return this.packages.get(environment.envId.id); } - - const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); - this.packages.set(environment.envId.id, packages); - return packages; } 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, + const versionCmd: PipVersionCommand | UvVersionCommand = await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipVersionCommand, + UvVersionCommand, ); - // "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; + return await versionCmd.execute(); } catch { return undefined; } @@ -179,49 +243,48 @@ 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; } - const baseVersion = parse(environment.version)?.base_version; + // Normalize versions like '3.13.1.final.0' (Python's sys.version_info format) to '3.13.1' + // before parsing, since pep440 only accepts valid PEP 440 version strings. + const versionMatch = (environment.version ?? '').match(/^(\d+(?:\.\d+)*)/); + const normalizedVersion = versionMatch?.[1] ?? ''; + const baseVersion = parse(normalizedVersion)?.base_version; 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); - } - // pip >= 25.1 - 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, '25.1') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, + const availableVersionsCmd: PipAvailableVersionsCommand | UvAvailableVersionsCommand = + await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipAvailableVersionsCommand, + UvAvailableVersionsCommand, ); - return parsePipIndexVersionsJson(output); - } - if (pipVersion && compare(pipVersion.public, '21.2') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsText(output); + // For pip < 21.2.0, check version first + if (availableVersionsCmd instanceof PipAvailableVersionsCommand) { + 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; + } + const versions = await availableVersionsCmd.execute({ + packageName, + pythonVersion: baseVersion, + useJson: compare(pipVersion.public, '25.1') >= 0, + }); + return versions.sort((a, b) => compare(b.public, a.public)); } - // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. + const versions = await availableVersionsCmd.execute({ + packageName, + pythonVersion: baseVersion, + }); + return versions.sort((a, b) => compare(b.public, a.public)); } catch { return undefined; } @@ -233,52 +296,25 @@ 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. * - * Note: These commands return packages with no installed dependents (leaf packages), not packages - * the user explicitly installed. pip/uv do not track install intent. + * pip uses `pip list --format=json --not-required`; uv uses `uv pip tree --depth=0`. + * + * Note: These return top-level packages (no installed dependents / roots of the + * dependency tree), not necessarily 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; - } -} - -/** Parses the legacy text output from `pip index versions `. */ -export function parsePipIndexVersionsText(output: string): Pep440Version[] | undefined { - const match = output.match(/^Available versions:\s*(.+)$/im); - if (!match) { - return undefined; + const listDirectNamesCmd: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipListDirectNamesCommand, + UvListDirectNamesCommand, + ); + return listDirectNamesCmd.execute(); } - const versions = match[1] - .split(',') - .map((version) => parse(version.trim())) - .filter((version): version is Pep440Version => version !== null) - .sort((a, b) => rcompare(a.public, b.public)); - return versions.length > 0 ? versions : undefined; } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 055ddb965..80f3a59bd 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -11,9 +11,11 @@ import { normalizePath } from '../../common/utils/pathUtils'; import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; +import { normalizePackageName } from '../common/packageUtils'; import { Installable } from '../common/types'; import { mergePackages } from '../common/utils'; -import { normalizePackageName, refreshPipPackages } from './utils'; +import { createPipOrUvCommand } from './commands/factory'; +import { PipListCommand, UvListCommand } from './commands/index'; export interface PyprojectToml { project?: { @@ -275,7 +277,22 @@ 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) { + try { + const listCmd: PipListCommand | UvListCommand = await createPipOrUvCommand( + { pythonExecutable, log }, + environment.environmentPath.fsPath, + PipListCommand, + UvListCommand, + ); + const data = await listCmd.execute(); + installed = data.map((pkg) => pkg.name); + } catch (error) { + log?.error('Error listing installed packages', error); + installed = []; + } + } common = mergePackages(common, installed ?? []); } return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []); @@ -394,10 +411,7 @@ export async function getProjectInstallable( !path.isAbsolute(relative)) ); }) - .sort( - (a, b) => - path.dirname(b.fsPath).length - path.dirname(a.fsPath).length, - ) + .sort((a, b) => path.dirname(b.fsPath).length - path.dirname(a.fsPath).length) : []; if (preferredCandidates.length > 0) { @@ -502,17 +516,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 f6ff2903a..a695462bb 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,8 +13,6 @@ 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'; @@ -183,155 +172,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; showErrors?: 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); - if (options?.showErrors !== false) { - 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 @@ -390,7 +230,3 @@ export async function resolveSystemPythonEnvironmentPath( } return undefined; } - -export function normalizePackageName(name: string): string { - return name.replace(/[-_.]+/g, '-').toLowerCase(); -} diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 6c484fccd..e3eaea9a0 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; -import { normalizePackageName } from '../builtin/utils'; +import { normalizePackageName } from './packageUtils'; /** * Callback invoked with the computed changes when at least one change is detected. diff --git a/src/managers/common/packageUtils.ts b/src/managers/common/packageUtils.ts new file mode 100644 index 000000000..bf06615ff --- /dev/null +++ b/src/managers/common/packageUtils.ts @@ -0,0 +1,16 @@ +/** + * Converts package specification strings to command package arguments. + * + * The version remains embedded in the package name because each package manager + * accepts its own specification syntax. + */ +export function parsePackageSpecs(packageStrings: string[]): { packageName: string; version?: string }[] { + return packageStrings.map((packageName) => ({ packageName })); +} + +/** + * Normalizes a Python package name according to PEP 503 comparison rules. + */ +export function normalizePackageName(name: string): string { + return name.replace(/[-_.]+/g, '-').toLowerCase(); +} diff --git a/src/managers/conda/commands/list.ts b/src/managers/conda/commands/list.ts index aeb989bcb..f2477ece8 100644 --- a/src/managers/conda/commands/list.ts +++ b/src/managers/conda/commands/list.ts @@ -22,16 +22,27 @@ export class CondaListCommand extends ListCommand { async execute(executeArgs?: BaseExecuteArgs): Promise { const output = await runCondaExecutable(this.buildCommand(), this.log, executeArgs?.cancellationToken); - let condaPackages: { name: string; version: string }[]; + let parsed: unknown; try { - condaPackages = JSON.parse(output) as { name: string; version: string }[]; - } catch { - return []; + parsed = JSON.parse(output); + } catch (error) { + this.log?.error('Failed to parse conda list output', error); + throw error; + } + if (!Array.isArray(parsed)) { + const error = new Error('Invalid conda list output: expected a JSON array'); + this.log?.error(error.message); + throw error; } const packages: PackageInfo[] = []; - for (const condaPkg of condaPackages) { - if (condaPkg.name && condaPkg.version) { + for (const condaPkg of parsed) { + if ( + typeof condaPkg === 'object' && + condaPkg !== null && + typeof condaPkg.name === 'string' && + typeof condaPkg.version === 'string' + ) { packages.push({ name: condaPkg.name, displayName: condaPkg.name, diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d395d0ce6..6116ea395 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,5 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { explain as parse, rcompare } from '@renovatebot/pep440'; +import { compare } from '@renovatebot/pep440'; import * as path from 'path'; import { CancellationError, @@ -23,10 +23,18 @@ 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 { updatePackagesAndNotify } from '../common/packageChanges'; -import { getCommonCondaPackagesToInstall, managePackages, runCondaExecutable } from './condaUtils'; +import { parsePackageSpecs } from '../common/packageUtils'; +import { + CondaAvailableVersionsCommand, + CondaInstallCommand, + CondaListCommand, + CondaUninstallCommand, + CondaVersionCommand, +} from './commands/index'; +import { getCommonCondaPackagesToInstall } from './condaUtils'; export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -67,11 +75,6 @@ export class CondaPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; await withProgress( { location: ProgressLocation.Notification, @@ -80,7 +83,29 @@ export class CondaPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - await managePackages(environment, manageOptions, token, this.log); + const commandOptions = { + pythonExecutable: 'conda', + condaEnvironmentPath: environment.environmentPath.fsPath, + log: this.log, + }; + + if (toUninstall.length > 0) { + const command = new CondaUninstallCommand(commandOptions); + await command.execute({ + packages: parsePackageSpecs(toUninstall), + cancellationToken: token, + }); + } + + if (toInstall.length > 0) { + const command = new CondaInstallCommand(commandOptions); + await command.execute({ + packages: parsePackageSpecs(toInstall), + upgrade: options.upgrade, + cancellationToken: token, + }); + } + await updatePackagesAndNotify( this, environment, @@ -93,9 +118,8 @@ export class CondaPackageManager implements PackageManager, Disposable { if (e instanceof CancellationError) { throw e; } - this.log.error('Error installing packages', e); - if (!manageOptions.runHeadless) { + if (!options.runHeadless) { setImmediate(async () => { await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); }); @@ -128,34 +152,13 @@ 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', + condaEnvironmentPath: environment.environmentPath.fsPath, + 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; } @@ -169,10 +172,11 @@ 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, + }); + return await versionCmd.execute(); } catch { return undefined; } @@ -183,25 +187,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 versions = await availableVersionsCmd.execute({ packageName, pythonVersion: '' }); + return versions.sort((a, b) => compare(b.public, a.public)); } catch { return undefined; } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 06d3ec54f..eee067ce3 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1250,29 +1250,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/showTopLevel.ts b/src/managers/poetry/commands/showTopLevel.ts index 96de5859e..2289508e5 100644 --- a/src/managers/poetry/commands/showTopLevel.ts +++ b/src/managers/poetry/commands/showTopLevel.ts @@ -1,5 +1,5 @@ import { ListDirectNamesCommand, type BaseExecuteArgs } from '../../base/commands/index'; -import { normalizePackageName } from '../../builtin/utils'; +import { normalizePackageName } from '../../common/packageUtils'; import { runPoetry } from './runPoetry'; /** diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index e946f0452..77fca3023 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -1,5 +1,4 @@ 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 { @@ -24,12 +23,18 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; -import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; -import { normalizePackageName } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; +import { parsePackageSpecs } from '../common/packageUtils'; +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(); @@ -162,8 +167,11 @@ 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, + }); + return await versionCmd.execute(); } async getPackageAvailableVersions( @@ -198,31 +206,24 @@ 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.execute({ packages, cancellationToken: token }); } // 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.execute({ packages, cancellationToken: token }); } } @@ -236,126 +237,67 @@ 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 }[] = []; - + const cwd = await this.getPoetryCwd(environment); + const showCmd = new PoetryShowCommand({ + pythonExecutable: poetry, + cwd, + log: this.log, + }); 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 + const data = await showCmd.execute(); + return data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + } catch (error) { + this.log.error(`Error refreshing packages with Poetry: ${error}`); return []; } - - // Convert to Package objects using the API - return poetryPackages.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, + }); + return await showTopLevelCmd.execute(); } catch (err) { this.log.error(`Error fetching direct package names with Poetry: ${err}`); return undefined; } } -} - -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(' ')}`); + private async getPoetryCwd(environment: PythonEnvironment): Promise { + const projects = this.api.getPythonProjects(); + if (projects.length === 0) { + return undefined; + } - 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 s = data.toString('utf-8'); - builder += s; - log?.append(`poetry: ${s}`); - }); - proc.stderr?.on('data', (data) => { - const s = data.toString('utf-8'); - builder += s; - log?.append(`poetry: ${s}`); - }); - proc.on('close', () => { - resolve(builder); - }); - proc.on('error', (error) => { - log?.error(`Error executing poetry command: ${error}`); - reject(error); - }); - proc.on('exit', (code) => { - if (code !== 0) { - reject(new Error(`Failed to run poetry ${args.join(' ')}`)); + const toDirectory = async (fsPath: string): Promise => { + try { + const stat = await fsapi.stat(fsPath); + return stat.isDirectory() ? fsPath : path.dirname(fsPath); + } catch { + return path.dirname(fsPath); } - }); - }); + }; + + if (projects.length === 1) { + return toDirectory(projects[0].uri.fsPath); + } + + const matchingDirectories = new Set(); + await Promise.all( + projects.map(async (project) => { + const projectEnvironment = await this.api.getEnvironment(project.uri); + if (projectEnvironment?.envId.id === environment.envId.id) { + matchingDirectories.add(await toDirectory(project.uri.fsPath)); + } + }), + ); + + return Array.from(matchingDirectories).sort((a, b) => b.length - a.length)[0]; + } } diff --git a/src/test/managers/builtin/commands.unit.test.ts b/src/test/managers/builtin/commands.unit.test.ts new file mode 100644 index 000000000..7f8713ba0 --- /dev/null +++ b/src/test/managers/builtin/commands.unit.test.ts @@ -0,0 +1,179 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PipAvailableVersionsCommand, UvAvailableVersionsCommand } from '../../../managers/builtin/commands/availableVersions'; +import { PipInstallCommand, UvInstallCommand } from '../../../managers/builtin/commands/install'; +import { PipListCommand, UvListCommand } from '../../../managers/builtin/commands/list'; +import { PipListDirectNamesCommand, UvListDirectNamesCommand } from '../../../managers/builtin/commands/listDirectNames'; +import { PipUninstallCommand, UvUninstallCommand } from '../../../managers/builtin/commands/uninstall'; +import { PipVersionCommand, UvVersionCommand } from '../../../managers/builtin/commands/version'; +import * as helpers from '../../../managers/builtin/helpers'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Pip and UV commands', () => { + let mockLog: LogOutputChannel; + let runPythonStub: sinon.SinonStub; + let runUvStub: sinon.SinonStub; + + setup(() => { + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runPythonStub = sinon.stub(helpers, 'runPython').resolves(''); + runUvStub = sinon.stub(helpers, 'runUV').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + test('PipInstallCommand builds upgrade and editable arguments', async () => { + const command = new PipInstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await command.execute({ + packages: [{ packageName: '-e' }, { packageName: '.' }, { packageName: '-e' }, { packageName: '.[dev]' }], + upgrade: true, + }); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], ['-m', 'pip', 'install', '--upgrade', '-e', '.[dev]']); + }); + + test('UvInstallCommand targets the selected interpreter', async () => { + const command = new UvInstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.deepStrictEqual(runUvStub.firstCall.args[0], ['pip', 'install', '--python', 'python', 'requests']); + }); + + test('PipUninstallCommand includes automatic confirmation', async () => { + const command = new PipUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], ['-m', 'pip', 'uninstall', '-y', 'requests']); + }); + + test('UvUninstallCommand targets the selected interpreter', async () => { + const command = new UvUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.deepStrictEqual(runUvStub.firstCall.args[0], ['pip', 'uninstall', '--python', 'python', 'requests']); + }); + + test('PipAvailableVersionsCommand parses JSON output for Pip 25.1+', async () => { + runPythonStub.resolves(JSON.stringify({ versions: ['2.0.0rc1', '1.0.0'] })); + const command = new PipAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute({ + packageName: 'package', + pythonVersion: '3.13.1', + includePrerelease: false, + useJson: true, + }); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], [ + '-m', + 'pip', + 'index', + 'versions', + 'package', + '--json', + '--python-version', + '3.13.1', + ]); + assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']); + }); + + test('PipAvailableVersionsCommand parses text output for Pip 21.2 through 25.0', async () => { + runPythonStub.resolves('Available versions: 2.0.0rc1, 1.0.0'); + const command = new PipAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute({ + packageName: 'package', + pythonVersion: '3.13.1', + includePrerelease: false, + useJson: false, + }); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], [ + '-m', + 'pip', + 'index', + 'versions', + 'package', + '--python-version', + '3.13.1', + ]); + assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']); + }); + + test('UvAvailableVersionsCommand parses embedded JSON', async () => { + runUvStub.resolves(`Some preamble\n${JSON.stringify({ versions: ['1.0.0'] })}`); + const command = new UvAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute({ packageName: 'package', pythonVersion: '3.13.1' }); + + assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0']); + }); + + test('PipListCommand parses packages and drops incomplete entries', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }, { name: 'broken' }])); + const command = new PipListCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual(runPythonStub.firstCall.args[1], [ + '-m', + 'pip', + 'list', + '--format=json', + '--disable-pip-version-check', + ]); + assert.deepStrictEqual( + result.map((pkg) => ({ name: pkg.name, version: pkg.version })), + [{ name: 'package', version: '1.0.0' }], + ); + }); + + test('UvListCommand parses packages from JSON', async () => { + runUvStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new UvListCommand({ pythonExecutable: 'python', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual(runUvStub.firstCall.args[0], ['pip', 'list', '--format=json', '--python', 'python']); + assert.deepStrictEqual(result.map((pkg) => pkg.name), ['package']); + }); + + test('direct-package commands normalize names and ignore UV dependencies', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'Flask_Thing' }])); + runUvStub.resolves(['Flask_Thing 1.0.0', '├── dependency 2.0.0'].join('\n')); + + const pipResult = await new PipListDirectNamesCommand({ + pythonExecutable: 'python', + log: mockLog, + }).execute(); + const uvResult = await new UvListDirectNamesCommand({ + pythonExecutable: 'python', + log: mockLog, + }).execute(); + + assert.deepStrictEqual([...pipResult], ['flask-thing']); + assert.deepStrictEqual([...uvResult], ['flask-thing']); + }); + + test('version commands parse tool versions', async () => { + runPythonStub.resolves('pip 24.0 from /site-packages/pip (python 3.13)'); + runUvStub.resolves('uv 0.4.20 (abcdef 2024-01-01)'); + + const pipVersion = await new PipVersionCommand({ pythonExecutable: 'python', log: mockLog }).execute(); + const uvVersion = await new UvVersionCommand({ pythonExecutable: 'python', log: mockLog }).execute(); + + assert.strictEqual(pipVersion?.public, '24.0'); + assert.strictEqual(uvVersion?.public, '0.4.20'); + }); +}); diff --git a/src/test/managers/builtin/normalizePackageName.unit.test.ts b/src/test/managers/builtin/normalizePackageName.unit.test.ts index cd59e39b3..b6cd8bf28 100644 --- a/src/test/managers/builtin/normalizePackageName.unit.test.ts +++ b/src/test/managers/builtin/normalizePackageName.unit.test.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import { normalizePackageName } from '../../../managers/builtin/utils'; +import { normalizePackageName } from '../../../managers/common/packageUtils'; suite('normalizePackageName', () => { test('should lowercase names', () => { diff --git a/src/test/managers/builtin/pipListUtils.unit.test.ts b/src/test/managers/builtin/pipListUtils.unit.test.ts deleted file mode 100644 index 0bc978e9f..000000000 --- a/src/test/managers/builtin/pipListUtils.unit.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import assert from 'assert'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as sinon from 'sinon'; -import { LogOutputChannel } from 'vscode'; -import { parsePipListJson, parseUvTree } from '../../../managers/builtin/pipListUtils'; -import { EXTENSION_TEST_ROOT } from '../../constants'; - -const TEST_DATA_ROOT = path.join(EXTENSION_TEST_ROOT, 'managers', 'builtin'); - -suite('Pip List JSON Parser tests', () => { - let log: LogOutputChannel; - - setup(() => { - log = { - error: sinon.stub(), - warn: sinon.stub(), - info: sinon.stub(), - } as unknown as LogOutputChannel; - }); - - teardown(() => { - sinon.restore(); - }); - - const testNames = ['piplist1', 'piplist2', 'piplist3']; - - testNames.forEach((testName) => { - test(`Test parsing pip list JSON output ${testName}`, async () => { - const expected = JSON.parse( - await fs.readFile(path.join(TEST_DATA_ROOT, `${testName}.expected.json`), 'utf8'), - ); - const pipListOutput = JSON.stringify(expected.packages); - - const actualPackages = parsePipListJson(pipListOutput, log); - - assert.equal(actualPackages.length, expected.packages.length, 'Unexpected number of packages'); - actualPackages.forEach((actualPackage) => { - const expectedPackage = expected.packages.find( - (item: { name: string }) => item.name === actualPackage.name, - ); - assert.ok(expectedPackage, `Package ${actualPackage.name} not found in expected packages`); - assert.equal(actualPackage.version, expectedPackage.version, 'Version mismatch'); - }); - - expected.packages.forEach((expectedPackage: { name: string; version: string }) => { - const actualPackage = actualPackages.find((item) => item.name === expectedPackage.name); - assert.ok(actualPackage, `Package ${expectedPackage.name} not found in actual packages`); - assert.equal(actualPackage.version, expectedPackage.version, 'Version mismatch'); - }); - }); - }); - - test('Returns an empty array for invalid JSON input', () => { - assert.deepStrictEqual(parsePipListJson('not json', log), []); - }); - - test('Logs error when JSON parsing fails', () => { - parsePipListJson('not valid json', log); - assert.ok((log.error as sinon.SinonStub).calledOnce, 'Expected error to be logged'); - }); - - test('Returns empty array without logging when no log is provided', () => { - const result = parsePipListJson('not valid json'); - assert.deepStrictEqual(result, []); - }); - - test('Skips items without a name or version', () => { - const actualPackages = parsePipListJson( - JSON.stringify([{ name: 'pip', version: '24.0' }, { name: 'setuptools' }, { version: '1.0.0' }]), - log, - ); - - assert.deepStrictEqual(actualPackages, [ - { - name: 'pip', - version: '24.0', - displayName: 'pip', - description: '24.0', - }, - ]); - }); - - test('Returns empty array for non-array JSON', () => { - const result = parsePipListJson('{"name": "pip"}', log); - assert.deepStrictEqual(result, []); - }); - - test('Returns empty array for empty array JSON', () => { - const result = parsePipListJson('[]', log); - assert.deepStrictEqual(result, []); - }); -}); - -suite('parseUvTree tests', () => { - test('Parses uv pip tree output with depth 0', () => { - const input = 'requests v2.31.0\nflask v3.0.0\n'; - const result = parseUvTree(input); - assert.deepStrictEqual(result, ['requests', 'flask']); - }); - - test('Handles empty output', () => { - assert.deepStrictEqual(parseUvTree(''), []); - }); - - test('Filters blank lines', () => { - const input = 'requests v2.31.0\n\n\nflask v3.0.0\n'; - const result = parseUvTree(input); - assert.deepStrictEqual(result, ['requests', 'flask']); - }); - - test('Handles single package', () => { - const input = 'pip v24.0\n'; - const result = parseUvTree(input); - assert.deepStrictEqual(result, ['pip']); - }); - - test('Trims leading whitespace from indented lines', () => { - const input = ' requests v2.31.0\n flask v3.0.0\n'; - const result = parseUvTree(input); - assert.deepStrictEqual(result, ['requests', 'flask']); - }); -}); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 549bdd2de..d2c9401f9 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -5,9 +5,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as helpers from '../../../managers/builtin/helpers'; import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; -import * as builtinUtils from '../../../managers/builtin/utils'; +import { PipListCommand } from '../../../managers/builtin/commands/list'; import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as windowApis from '../../../common/window.apis'; suite('PipPackageManager', () => { teardown(() => { @@ -17,7 +19,8 @@ suite('PipPackageManager', () => { test('preserves cached packages when a forced refresh fails', async () => { const environment = { envId: { id: 'test-environment', managerId: 'test-manager' }, - environmentPath: Uri.file('/path/to/environment'), + environmentPath: Uri.file('.'), + execInfo: { run: { executable: 'python' } }, } as PythonEnvironment; const cachedPackage = { name: 'pip', version: '25.0' } as Package; const api = { @@ -28,11 +31,13 @@ suite('PipPackageManager', () => { info: sinon.stub(), } as unknown as LogOutputChannel; const manager = new PipPackageManager(api, log, {} as VenvManager); - const refreshPackages = sinon.stub(builtinUtils, 'refreshPipPackages'); - refreshPackages + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const execute = sinon.stub(PipListCommand.prototype, 'execute'); + execute .onFirstCall() .resolves([{ name: 'pip', version: '25.0', displayName: 'pip', description: '25.0' }]); - refreshPackages.onSecondCall().resolves(undefined); + execute.onSecondCall().rejects(new Error('pip list failed')); const initial = await manager.getPackages(environment); const afterFailedRefresh = await manager.getPackages(environment, { skipCache: true }); @@ -44,20 +49,23 @@ suite('PipPackageManager', () => { test('preserves undefined when an uncached refresh fails', async () => { const environment = { envId: { id: 'test-environment', managerId: 'test-manager' }, - environmentPath: Uri.file('/path/to/environment'), + environmentPath: Uri.file('.'), + execInfo: { run: { executable: 'python' } }, } as PythonEnvironment; const manager = new PipPackageManager( { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, {} as VenvManager, ); - const refreshPackages = sinon.stub(builtinUtils, 'refreshPipPackages').resolves(undefined); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + const execute = sinon.stub(PipListCommand.prototype, 'execute').rejects(new Error('pip list failed')); const firstResult = await manager.getPackages(environment); const secondResult = await manager.getPackages(environment); assert.strictEqual(firstResult, undefined); assert.strictEqual(secondResult, undefined); - assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); + assert.strictEqual(execute.callCount, 2, 'A failed refresh should not populate the package cache'); }); }); diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts index dff10003c..6ebb4df2e 100644 --- a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -4,19 +4,26 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { PythonEnvironment } from '../../../api'; -import * as errorUtils from '../../../common/errors/utils'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import { PipInstallCommand } from '../../../managers/builtin/commands/install'; +import { PipListCommand } from '../../../managers/builtin/commands/list'; +import { PipListDirectNamesCommand } from '../../../managers/builtin/commands/listDirectNames'; import * as helpers from '../../../managers/builtin/helpers'; -import { refreshPipPackages } from '../../../managers/builtin/utils'; +import { PipPackageManager } from '../../../managers/builtin/pipPackageManager'; +import { VenvManager } from '../../../managers/builtin/venvManager'; suite('Pip package refresh', () => { let environment: PythonEnvironment; let log: LogOutputChannel; - let showErrorMessageWithLogsStub: sinon.SinonStub; + let manager: PipPackageManager; + let showErrorMessageStub: sinon.SinonStub; setup(() => { environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, environmentPath: Uri.file('.'), + version: '3.13.0', execInfo: { run: { executable: 'python', @@ -27,10 +34,14 @@ suite('Pip package refresh', () => { error: sinon.stub(), info: sinon.stub(), } as unknown as LogOutputChannel; + manager = new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + log, + {} as VenvManager, + ); sinon.stub(helpers, 'shouldUseUv').resolves(false); - sinon.stub(helpers, 'runPython').rejects(new Error('pip list failed')); - showErrorMessageWithLogsStub = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); }); teardown(() => { @@ -38,16 +49,23 @@ suite('Pip package refresh', () => { }); test('shows an error when an interactive refresh fails', async () => { - const result = await refreshPipPackages(environment, log); + sinon.stub(PipListCommand.prototype, 'execute').rejects(new Error('pip list failed')); + const result = await manager.getPackages(environment, { skipCache: true }); + await new Promise((resolve) => setImmediate(resolve)); assert.strictEqual(result, undefined); - assert.ok(showErrorMessageWithLogsStub.calledOnce); + assert.ok(showErrorMessageStub.calledOnce); }); test('does not show an error when a headless refresh fails', async () => { - const result = await refreshPipPackages(environment, log, { showErrors: false }); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(PipInstallCommand.prototype, 'execute').resolves(); + sinon.stub(PipListCommand.prototype, 'execute').rejects(new Error('pip list failed')); + sinon.stub(PipListDirectNamesCommand.prototype, 'execute').resolves(undefined); - assert.strictEqual(result, undefined); - assert.ok(showErrorMessageWithLogsStub.notCalled); + await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok(showErrorMessageStub.notCalled); }); }); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 1cbeeec12..adccc465c 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1,13 +1,15 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationToken, Progress, ProgressOptions, Uri } from 'vscode'; +import { CancellationToken, LogOutputChannel, Progress, ProgressOptions, Uri } from 'vscode'; import * as fse from 'fs-extra'; import * as os from 'os'; -import { PythonEnvironmentApi, PythonProject } from '../../../api'; +import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; -import { getProjectInstallable } from '../../../managers/builtin/pipUtils'; +import { PipListCommand } from '../../../managers/builtin/commands/list'; +import * as helpers from '../../../managers/builtin/helpers'; +import { getProjectInstallable, getWorkspacePackagesToInstall } from '../../../managers/builtin/pipUtils'; suite('Pip Utils - getProjectInstallable', () => { let findFilesStub: sinon.SinonStub; @@ -47,6 +49,46 @@ suite('Pip Utils - getProjectInstallable', () => { }; }); + suite('Pip Utils - getWorkspacePackagesToInstall', () => { + teardown(() => { + sinon.restore(); + }); + + test('opens the package picker when listing installed packages fails', async () => { + const listError = new Error('pip list failed'); + const logError = sinon.stub(); + const log = { error: logError } as unknown as LogOutputChannel; + const environment = { + environmentPath: Uri.file('.'), + execInfo: { run: { executable: 'python' } }, + } as PythonEnvironment; + const workspacePath = Uri.file('/test/path/root').fsPath; + findFilesStub.callsFake((pattern: string) => + Promise.resolve( + pattern === '*requirements*.txt' + ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] + : [], + ), + ); + + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(PipListCommand.prototype, 'execute').rejects(listError); + const showQuickPick = sinon.stub(winapi, 'showQuickPickWithButtons').resolves(undefined); + + const result = await getWorkspacePackagesToInstall( + mockApi as PythonEnvironmentApi, + { install: [], showSkipOption: true }, + [{ name: 'workspace', uri: Uri.file(workspacePath) }], + environment, + log, + ); + + assert.strictEqual(result, undefined); + assert.ok(logError.calledOnceWithExactly('Error listing installed packages', listError)); + assert.ok(showQuickPick.calledOnce, 'The package picker should still open after a list failure'); + }); + }); + teardown(() => { sinon.restore(); }); 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 b2bd15f6b..000000000 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { explain } from '@renovatebot/pep440'; -import assert from 'assert'; -import { parsePipIndexVersionsJson, parsePipIndexVersionsText } 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); - }); - }); - - suite('parsePipIndexVersionsText', () => { - test('parses and sorts the available versions line', () => { - const output = [ - 'requests (2.32.5)', - 'Available versions: 2.31.0, 2.32.5, 2.30.0', - ' INSTALLED: 2.31.0', - ' LATEST: 2.32.5', - ].join('\n'); - const versions = parsePipIndexVersionsText(output); - assert.deepStrictEqual( - versions, - ['2.32.5', '2.31.0', '2.30.0'].map((version) => explain(version)), - ); - }); - - test('returns undefined when the available versions line is missing', () => { - assert.strictEqual(parsePipIndexVersionsText('ERROR: No matching distribution found'), undefined); - }); - - test('ignores invalid versions', () => { - const versions = parsePipIndexVersionsText('Available versions: invalid, 1.2.3'); - assert.deepStrictEqual(versions, [explain('1.2.3')]); - }); - }); -}); diff --git a/src/test/managers/conda/commands.unit.test.ts b/src/test/managers/conda/commands.unit.test.ts new file mode 100644 index 000000000..e8190cc8c --- /dev/null +++ b/src/test/managers/conda/commands.unit.test.ts @@ -0,0 +1,135 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { CondaAvailableVersionsCommand } from '../../../managers/conda/commands/availableVersions'; +import { CondaInstallCommand } from '../../../managers/conda/commands/install'; +import { CondaListCommand } from '../../../managers/conda/commands/list'; +import { CondaUninstallCommand } from '../../../managers/conda/commands/uninstall'; +import { CondaVersionCommand } from '../../../managers/conda/commands/version'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Conda commands', () => { + let environmentPath: string; + let mockLog: LogOutputChannel; + let runCondaStub: sinon.SinonStub; + + setup(() => { + environmentPath = Uri.file('environment').fsPath; + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runCondaStub = sinon.stub(condaUtils, 'runCondaExecutable').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + test('CondaInstallCommand builds prefix, upgrade, and version arguments', async () => { + const command = new CondaInstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + await command.execute({ packages: [{ packageName: 'requests', version: '2.32.0' }], upgrade: true }); + + assert.deepStrictEqual(runCondaStub.firstCall.args[0], [ + 'install', + '--prefix', + environmentPath, + '--yes', + '--update-all', + 'requests=2.32.0', + ]); + }); + + test('CondaUninstallCommand builds environment-specific arguments', async () => { + const command = new CondaUninstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.deepStrictEqual(runCondaStub.firstCall.args[0], ['remove', '-y', '-p', environmentPath, 'requests']); + }); + + test('CondaAvailableVersionsCommand parses versions keyed by package name', async () => { + runCondaStub.resolves(JSON.stringify({ package: [{ version: '1.0.0' }, { version: '2.0.0' }] })); + const command = new CondaAvailableVersionsCommand({ pythonExecutable: 'conda', log: mockLog }); + + const result = await command.execute({ packageName: 'package', pythonVersion: '' }); + + assert.deepStrictEqual(runCondaStub.firstCall.args[0], ['search', 'package', '--json']); + assert.deepStrictEqual(result.map((version) => version.public), ['1.0.0', '2.0.0']); + }); + + test('CondaListCommand parses packages and drops incomplete entries', async () => { + runCondaStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }, { name: 'broken' }])); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + const result = await command.execute(); + + assert.deepStrictEqual(runCondaStub.firstCall.args[0], ['list', '-p', environmentPath, '--json']); + assert.deepStrictEqual( + result.map((pkg) => ({ name: pkg.name, version: pkg.version })), + [{ name: 'package', version: '1.0.0' }], + ); + }); + + test('CondaListCommand accepts a successfully parsed empty environment', async () => { + runCondaStub.resolves('[]'); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + const result = await command.execute(); + + assert.deepStrictEqual(result, []); + }); + + test('CondaListCommand rejects malformed JSON', async () => { + runCondaStub.resolves('not json'); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + await assert.rejects(() => command.execute(), SyntaxError); + assert.ok((mockLog.error as sinon.SinonStub).calledOnce); + }); + + test('CondaListCommand rejects non-array JSON', async () => { + runCondaStub.resolves('{}'); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: mockLog, + }); + + await assert.rejects(() => command.execute(), /expected a JSON array/); + assert.ok((mockLog.error as sinon.SinonStub).calledOnce); + }); + + test('CondaVersionCommand parses the version', async () => { + runCondaStub.resolves('conda 24.1.2'); + const command = new CondaVersionCommand({ pythonExecutable: 'conda', log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual(runCondaStub.firstCall.args[0], ['--version']); + assert.strictEqual(result?.public, '24.1.2'); + }); +}); diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts index ea6614daf..d8a77da58 100644 --- a/src/test/managers/conda/condaPackageManager.unit.test.ts +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -7,8 +7,9 @@ import { LogOutputChannel, Uri } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import * as errorUtils from '../../../common/errors/utils'; import * as windowApis from '../../../common/window.apis'; +import { CondaInstallCommand } from '../../../managers/conda/commands/install'; +import { CondaListCommand } from '../../../managers/conda/commands/list'; import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; -import * as condaUtils from '../../../managers/conda/condaUtils'; suite('CondaPackageManager', () => { teardown(() => { @@ -18,7 +19,7 @@ suite('CondaPackageManager', () => { test('headless package failures reject without showing error UI', async () => { const environment = { envId: { id: 'test-environment', managerId: 'test-manager' }, - environmentPath: Uri.file('/path/to/environment'), + environmentPath: Uri.file('.'), } as PythonEnvironment; const logError = sinon.stub(); const log = { @@ -26,7 +27,7 @@ suite('CondaPackageManager', () => { } as unknown as LogOutputChannel; const manager = new CondaPackageManager({} as PythonEnvironmentApi, log); const operationError = new Error('conda install failed'); - sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(CondaInstallCommand.prototype, 'execute').rejects(operationError); sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); @@ -38,4 +39,34 @@ suite('CondaPackageManager', () => { assert.ok(logError.calledOnce); assert.ok(showErrorMessageWithLogs.notCalled); }); + + test('retries package listing after a parse failure and caches only success', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('.'), + } as PythonEnvironment; + const api = { + createPackageItem: sinon.stub().callsFake((pkg) => pkg), + } as unknown as PythonEnvironmentApi; + const manager = new CondaPackageManager(api, { error: sinon.stub() } as unknown as LogOutputChannel); + const parseError = new SyntaxError('Unexpected token'); + const execute = sinon.stub(CondaListCommand.prototype, 'execute'); + execute.onFirstCall().rejects(parseError); + execute.onSecondCall().resolves([ + { + name: 'requests', + displayName: 'requests', + version: '2.32.0', + description: '2.32.0', + }, + ]); + + await assert.rejects(manager.getPackages(environment), (error: unknown) => error === parseError); + const packages = await manager.getPackages(environment); + const cachedPackages = await manager.getPackages(environment); + + assert.deepStrictEqual(packages?.map((pkg) => pkg.name), ['requests']); + assert.deepStrictEqual(cachedPackages?.map((pkg) => pkg.name), ['requests']); + assert.strictEqual(execute.callCount, 2, 'Only the successful list result should populate the cache'); + }); }); diff --git a/src/test/managers/poetry/commands.unit.test.ts b/src/test/managers/poetry/commands.unit.test.ts new file mode 100644 index 000000000..fd5c33a5f --- /dev/null +++ b/src/test/managers/poetry/commands.unit.test.ts @@ -0,0 +1,83 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PoetryAddCommand } from '../../../managers/poetry/commands/add'; +import { PoetryRemoveCommand } from '../../../managers/poetry/commands/remove'; +import * as poetryRunner from '../../../managers/poetry/commands/runPoetry'; +import { PoetryShowCommand } from '../../../managers/poetry/commands/show'; +import { PoetryShowTopLevelCommand } from '../../../managers/poetry/commands/showTopLevel'; +import { PoetryVersionCommand } from '../../../managers/poetry/commands/version'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; + +suite('Poetry commands', () => { + let cwd: string; + let mockLog: LogOutputChannel; + let runPoetryStub: sinon.SinonStub; + + setup(() => { + cwd = Uri.file('project').fsPath; + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runPoetryStub = sinon.stub(poetryRunner, 'runPoetry').resolves(''); + sinon.stub(poetryUtils, 'getPoetryVersion').resolves('1.8.2'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('PoetryAddCommand builds versioned arguments and preserves cwd', async () => { + const command = new PoetryAddCommand({ pythonExecutable: 'poetry', cwd, log: mockLog }); + + await command.execute({ packages: [{ packageName: 'requests', version: '2.32.0' }] }); + + assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [['add', 'requests@2.32.0'], cwd]); + }); + + test('PoetryRemoveCommand builds arguments and preserves cwd', async () => { + const command = new PoetryRemoveCommand({ pythonExecutable: 'poetry', cwd, log: mockLog }); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [['remove', 'requests'], cwd]); + }); + + test('PoetryShowCommand parses packages and preserves cwd', async () => { + runPoetryStub.resolves(['requests 2.31.0 Python HTTP for Humans.', ''].join('\n')); + const command = new PoetryShowCommand({ pythonExecutable: 'poetry', cwd, log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [['show', '--no-ansi'], cwd]); + assert.deepStrictEqual( + result.map((pkg) => ({ name: pkg.name, version: pkg.version, description: pkg.description })), + [{ name: 'requests', version: '2.31.0', description: '2.31.0 - Python HTTP for Humans.' }], + ); + }); + + test('PoetryShowTopLevelCommand returns normalized names', async () => { + runPoetryStub.resolves(['Flask_Thing', 'requests'].join('\n')); + const command = new PoetryShowTopLevelCommand({ pythonExecutable: 'poetry', cwd, log: mockLog }); + + const result = await command.execute(); + + assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [ + ['show', '--no-ansi', '--top-level'], + cwd, + ]); + assert.deepStrictEqual([...result], ['flask-thing', 'requests']); + }); + + test('PoetryVersionCommand parses the configured Poetry version', async () => { + const command = new PoetryVersionCommand({ pythonExecutable: 'poetry', log: mockLog }); + + const result = await command.execute(); + + assert.ok((poetryUtils.getPoetryVersion as sinon.SinonStub).calledOnceWithExactly('poetry')); + assert.strictEqual(result?.public, '1.8.2'); + }); +}); diff --git a/src/test/managers/poetry/poetryPackageManager.unit.test.ts b/src/test/managers/poetry/poetryPackageManager.unit.test.ts new file mode 100644 index 000000000..e96cf746a --- /dev/null +++ b/src/test/managers/poetry/poetryPackageManager.unit.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PythonEnvironmentApi } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import * as packageChanges from '../../../managers/common/packageChanges'; +import * as runPoetryModule from '../../../managers/poetry/commands/runPoetry'; +import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; +import { PoetryManager } from '../../../managers/poetry/poetryManager'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('PoetryPackageManager', () => { + const environment = createMockPythonEnvironment({ + envPath: path.join(process.cwd(), '.venv'), + managerId: 'ms-python.python:poetry', + }); + let logError: sinon.SinonStub; + let manager: PoetryPackageManager; + let runPoetryStub: sinon.SinonStub; + + setup(() => { + const api = { + getPythonProjects: () => [ + { + name: 'project', + uri: Uri.file(path.join(process.cwd(), 'project', 'pyproject.toml')), + }, + ], + } as unknown as PythonEnvironmentApi; + logError = sinon.stub(); + const log = { + append: sinon.stub(), + error: logError, + info: sinon.stub(), + show: sinon.stub(), + } as unknown as LogOutputChannel; + + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + sinon.stub(windowApis, 'withProgress').callsFake((_options, task) => task({} as never, {} as never)); + sinon.stub(packageChanges, 'updatePackagesAndNotify').resolves([]); + runPoetryStub = sinon.stub(runPoetryModule, 'runPoetry').resolves(''); + manager = new PoetryPackageManager(api, log, {} as PoetryManager); + }); + + teardown(() => { + manager.dispose(); + sinon.restore(); + }); + + test('package management inherits the process working directory', async () => { + await manager.manage(environment, { install: ['requests'], uninstall: ['flask'] }); + + assert.strictEqual(runPoetryStub.callCount, 2); + assert.strictEqual(runPoetryStub.firstCall.args[1], undefined); + assert.strictEqual(runPoetryStub.secondCall.args[1], undefined); + }); + + test('direct package listing inherits the process working directory', async () => { + await manager.getDirectPackageNames(environment); + + assert.strictEqual(runPoetryStub.callCount, 1); + assert.strictEqual(runPoetryStub.firstCall.args[1], undefined); + }); + + test('package loading returns an empty list when poetry show fails', async () => { + const showError = new Error('poetry show failed'); + runPoetryStub.rejects(showError); + + const packages = await manager.getPackages(environment, { skipCache: true }); + + assert.deepStrictEqual(packages, []); + assert.ok(logError.calledOnceWithExactly(`Error refreshing packages with Poetry: ${showError}`)); + }); +});