From cfe1e49c9f75b1c5b323e8fbe6ba7fcc4ea6fe80 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 28 Jul 2026 11:08:51 -0700 Subject: [PATCH 1/6] Add package manager command smoke tests --- .../managers/builtin/commands.unit.test.ts | 111 ++++++++++++++++++ src/test/managers/conda/commands.unit.test.ts | 73 ++++++++++++ .../managers/poetry/commands.unit.test.ts | 62 ++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/test/managers/builtin/commands.unit.test.ts create mode 100644 src/test/managers/conda/commands.unit.test.ts create mode 100644 src/test/managers/poetry/commands.unit.test.ts 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..808203731 --- /dev/null +++ b/src/test/managers/builtin/commands.unit.test.ts @@ -0,0 +1,111 @@ +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('PipAvailableVersionsCommand executes without error', async () => { + runPythonStub.resolves(JSON.stringify({ versions: ['1.0.0'] })); + const command = new PipAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '3.13.1' })); + }); + + test('UvAvailableVersionsCommand executes without error', async () => { + runUvStub.resolves(JSON.stringify({ versions: ['1.0.0'] })); + const command = new UvAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '3.13.1' })); + }); + + test('PipInstallCommand executes without error', async () => { + const command = new PipInstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('UvInstallCommand executes without error', async () => { + const command = new UvInstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PipUninstallCommand executes without error', async () => { + const command = new PipUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('UvUninstallCommand executes without error', async () => { + const command = new UvUninstallCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PipListCommand executes without error', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new PipListCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('UvListCommand executes without error', async () => { + runUvStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new UvListCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('PipListDirectNamesCommand executes without error', async () => { + runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new PipListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('UvListDirectNamesCommand executes without error', async () => { + runUvStub.resolves('package 1.0.0'); + const command = new UvListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('PipVersionCommand executes without error', async () => { + runPythonStub.resolves('pip 24.0 from site-packages'); + const command = new PipVersionCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('UvVersionCommand executes without error', async () => { + runUvStub.resolves('uv 0.4.20'); + const command = new UvVersionCommand({ pythonExecutable: 'python', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); +}); \ No newline at end of file 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..8b1d11f48 --- /dev/null +++ b/src/test/managers/conda/commands.unit.test.ts @@ -0,0 +1,73 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } 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 mockLog: LogOutputChannel; + let runCondaStub: sinon.SinonStub; + + setup(() => { + mockLog = createMockLogOutputChannel(); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: () => undefined, + } as unknown as ReturnType); + runCondaStub = sinon.stub(condaUtils, 'runCondaExecutable').resolves(''); + }); + + teardown(() => { + sinon.restore(); + }); + + test('CondaAvailableVersionsCommand executes without error', async () => { + runCondaStub.resolves(JSON.stringify({ package: [{ version: '1.0.0' }] })); + const command = new CondaAvailableVersionsCommand({ pythonExecutable: 'conda', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '' })); + }); + + test('CondaInstallCommand executes without error', async () => { + const command = new CondaInstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('CondaUninstallCommand executes without error', async () => { + const command = new CondaUninstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('CondaListCommand executes without error', async () => { + runCondaStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); + const command = new CondaListCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: 'environment', + log: mockLog, + }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('CondaVersionCommand executes without error', async () => { + runCondaStub.resolves('conda 24.1.2'); + const command = new CondaVersionCommand({ pythonExecutable: 'conda', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); +}); \ No newline at end of file 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..4deb7b058 --- /dev/null +++ b/src/test/managers/poetry/commands.unit.test.ts @@ -0,0 +1,62 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel } 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 mockLog: LogOutputChannel; + let runPoetryStub: sinon.SinonStub; + + setup(() => { + 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 executes without error', async () => { + const command = new PoetryAddCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PoetryRemoveCommand executes without error', async () => { + const command = new PoetryRemoveCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); + }); + + test('PoetryShowCommand executes without error', async () => { + runPoetryStub.resolves('package 1.0.0 description'); + const command = new PoetryShowCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('PoetryShowTopLevelCommand executes without error', async () => { + runPoetryStub.resolves('package'); + const command = new PoetryShowTopLevelCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); + + test('PoetryVersionCommand executes without error', async () => { + const command = new PoetryVersionCommand({ pythonExecutable: 'poetry', log: mockLog }); + + await assert.doesNotReject(() => command.execute()); + }); +}); \ No newline at end of file From b18db3d2cc167951eb4b156c26872e1408ac8a79 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 28 Jul 2026 10:23:20 -0700 Subject: [PATCH 2/6] Adopt package manager command classes --- package-lock.json | 6 +- src/managers/builtin/helpers.ts | 2 +- src/managers/builtin/pipListUtils.ts | 34 --- src/managers/builtin/pipPackageManager.ts | 245 +++++++++------- src/managers/builtin/pipUtils.ts | 35 +-- src/managers/builtin/utils.ts | 175 +----------- src/managers/conda/condaPackageManager.ts | 173 ++++++----- src/managers/conda/condaUtils.ts | 23 -- src/managers/poetry/poetryPackageManager.ts | 269 +++++++----------- .../builtin/pipListUtils.unit.test.ts | 123 -------- .../managers/builtin/pipVersions.unit.test.ts | 37 --- 11 files changed, 362 insertions(+), 760 deletions(-) delete mode 100644 src/managers/builtin/pipListUtils.ts delete mode 100644 src/test/managers/builtin/pipListUtils.unit.test.ts delete mode 100644 src/test/managers/builtin/pipVersions.unit.test.ts diff --git a/package-lock.json b/package-lock.json index ac2ba7b61..e71917ced 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6909,7 +6909,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "node_modules/tunnel": { "version": "0.0.6", @@ -12354,7 +12355,8 @@ "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "tunnel": { "version": "0.0.6", diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 7fb7062a2..666775163 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -153,7 +153,7 @@ export async function runPython( proc.stderr?.on('data', (data) => { const s = data.toString('utf-8'); builder += s; - log?.append(`python: ${s}`); + log?.append(s); }); proc.on('close', () => { resolve(builder); 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 f0e09a7d0..36d4e5968 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -1,5 +1,5 @@ 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, @@ -21,10 +21,26 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { withProgress } from '../../common/window.apis'; +import { CommandConstructorOptions } from '../base/commands/index'; import { updatePackagesAndNotify } from '../common/packageChanges'; -import { runPython, runUV, shouldUseUv } from './helpers'; +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 { parsePackageSpecs } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -65,43 +81,66 @@ export class PipPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Installing packages', - cancellable: true, - }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages', e); - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - throw e; + try { + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error('Unable to determine Python executable path'); + } + + // Centralize command options for install/uninstall operations + const manageCommandOptions: CommandConstructorOptions = { + pythonExecutable, + log: this.log, + }; + + // Execute uninstall if needed + if (toUninstall.length > 0) { + const uninstallCmd: PipUninstallCommand | UvUninstallCommand = await createPipOrUvCommand( + manageCommandOptions, + environment.environmentPath.fsPath, + PipUninstallCommand, + UvUninstallCommand, + ); + const packages = parsePackageSpecs(toUninstall); + await withProgress( + { location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true }, + (_progress, token) => uninstallCmd.execute({ packages, cancellationToken: token }), + ); + } + + // Execute install if needed + if (toInstall.length > 0) { + const installCmd: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand( + manageCommandOptions, + environment.environmentPath.fsPath, + PipInstallCommand, + UvInstallCommand, + ); + const packages = parsePackageSpecs(toInstall); + await withProgress( + { location: ProgressLocation.Notification, title: 'Installing packages', cancellable: true }, + (_progress, token) => + installCmd.execute({ packages, upgrade: options.upgrade, cancellationToken: token }), + ); + } + + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + if (e instanceof CancellationError) { + // Cancellation is a normal control-flow exit; do not surface an error. + return; + } + this.log.error('Error managing packages', e); + setImmediate(async () => { + const result = await window.showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); } - }, - ); + }); + throw e; + } } async refresh(environment: PythonEnvironment): Promise { @@ -125,7 +164,20 @@ export class PipPackageManager implements PackageManager, Disposable { async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const data = await refreshPipPackages(environment, this.log); + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; + } + const listCmd: PipListCommand | UvListCommand = await createPipOrUvCommand( + { + pythonExecutable, + log: this.log, + }, + environment.environmentPath.fsPath, + PipListCommand, + UvListCommand, + ); + const data = await listCmd.execute(); const packages = (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); this.packages.set(environment.envId.id, packages); return packages; @@ -135,22 +187,17 @@ export class PipPackageManager implements PackageManager, Disposable { async getVersion(environment: PythonEnvironment): Promise { try { - const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const result = await runUV(['--version'], undefined, this.log); - // "uv X.Y.Z" - const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/); - return match ? (parse(match[1]) ?? undefined) : undefined; + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; } - const result = await runPython( - environment.execInfo?.run?.executable ?? 'python', - ['-m', 'pip', '--version'], - undefined, - this.log, + 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; } @@ -161,39 +208,45 @@ 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 >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. - const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, + const availableVersionsCmd: PipAvailableVersionsCommand | UvAvailableVersionsCommand = + await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipAvailableVersionsCommand, + UvAvailableVersionsCommand, ); - return parsePipIndexVersionsJson(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; + } } - // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + const versionStrings = await availableVersionsCmd.execute({ + packageName, + pythonVersion: baseVersion, + }); + return versionStrings + .map((v) => parse(v)) + .filter((parsed): parsed is Pep440Version => parsed !== null) + .sort((a, b) => compare(b.public, a.public)); } catch { return undefined; } @@ -205,38 +258,22 @@ export class PipPackageManager implements PackageManager, Disposable { } /** - * Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip tree --depth=0`. + * Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip list --not-required`. * * Note: These commands return packages with no installed dependents (leaf packages), not packages * the user explicitly installed. pip/uv do not track install intent. */ async getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { - const data = await refreshPipDirectPackageNames(environment, this.log); - return data ? new Set(data.map(normalizePackageName)) : undefined; - } -} - -/** - * Parses JSON output from `pip index versions --json`. - * Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] } - */ -export function parsePipIndexVersionsJson(output: string): Pep440Version[] | undefined { - // Only capture output between braces - const match = output.match(/{[\s\S]*}/); - if (!match) { - return undefined; - } - try { - const parsed = JSON.parse(match[0]); - if (parsed && Array.isArray(parsed.versions) && parsed.versions.length > 0) { - return (parsed.versions as string[]) - .filter((v) => !!v.trim()) - .map((v) => parse(v.trim())) - .filter((v): v is Pep440Version => v !== null) - .sort((a, b) => rcompare(a.public, b.public)); + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + return undefined; } - return undefined; - } catch { - return undefined; + const listDirectNamesCmd: PipListDirectNamesCommand | UvListDirectNamesCommand = await createPipOrUvCommand( + { pythonExecutable, log: this.log }, + environment.environmentPath.fsPath, + PipListDirectNamesCommand, + UvListDirectNamesCommand, + ); + return listDirectNamesCmd.execute(); } } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 055ddb965..54795999d 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -13,7 +13,9 @@ import { findFiles } from '../../common/workspace.apis'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; 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'; +import { normalizePackageName } from './utils'; export interface PyprojectToml { project?: { @@ -275,7 +277,17 @@ export async function getWorkspacePackagesToInstall( let common = await getCommonPackages(); let installed: string[] | undefined; if (environment) { - installed = (await refreshPipPackages(environment, log, { showProgress: true }))?.map((pkg) => pkg.name); + const pythonExecutable = environment.execInfo?.run?.executable; + if (pythonExecutable) { + const listCmd: PipListCommand | UvListCommand = await createPipOrUvCommand( + { pythonExecutable, log }, + environment.environmentPath.fsPath, + PipListCommand, + UvListCommand, + ); + const data = await listCmd.execute(); + installed = data?.map((pkg) => pkg.name); + } common = mergePackages(common, installed ?? []); } return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []); @@ -394,10 +406,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 +511,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 dc44fe759..046348035 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -1,19 +1,10 @@ -import { CancellationToken, LogOutputChannel, ProgressLocation, QuickPickItem, Uri, window } from 'vscode'; -import { - EnvironmentManager, - Package, - PackageManagementOptions, - PackageManager, - PythonEnvironment, - PythonEnvironmentApi, - PythonEnvironmentInfo, -} from '../../api'; -import { showErrorMessageWithLogs } from '../../common/errors/utils'; +import { LogOutputChannel, QuickPickItem, Uri, window } from 'vscode'; +import { EnvironmentManager, Package, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; import { getExtension } from '../../common/extension.apis'; import { Common, PixiStrings, SysManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; -import { showInformationMessage, withProgress } from '../../common/window.apis'; +import { showInformationMessage } from '../../common/window.apis'; import { openExtension } from '../../common/workbenchCommands'; import { isNativeEnvInfo, @@ -22,13 +13,22 @@ import { NativePythonFinder, } from '../common/nativePythonFinder'; import { shortenVersionString, sortEnvironments } from '../common/utils'; -import { runPython, runUV, shouldUseUv } from './helpers'; -import { parsePipListJson, parseUvTree, PipPackage } from './pipListUtils'; const PIXI_EXTENSION_ID = 'renan-r-santos.pixi-code'; const PIXI_RECOMMEND_DONT_ASK_KEY = 'pixi-extension-recommend-dont-ask'; let pixiRecommendationShown = false; +/** + * Parse package specifications (strings) into package objects. + * Each string becomes a package object with packageName and empty version. + */ +export function parsePackageSpecs(packageStrings: string[]): { packageName: string; version?: string }[] { + return packageStrings.map((pkg) => ({ + packageName: pkg, + version: undefined, + })); +} + function asPackageQuickPickItem(name: string, version?: string): QuickPickItem { return { label: name, @@ -183,153 +183,6 @@ export async function refreshPythons( return sortEnvironments(collection); } -const PIP_LIST_TIMEOUT_MS = 30_000; - -async function execPipList(environment: PythonEnvironment, log?: LogOutputChannel, args?: string[]): Promise { - // Use environmentPath directly for consistency with UV environment tracking - const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); - if (useUv) { - return await runUV( - ['pip', 'list', '--python', environment.execInfo.run.executable, '--format=json', ...(args ?? [])], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - } - try { - return await runPython( - environment.execInfo.run.executable, - ['-m', 'pip', 'list', '--format=json', ...(args ?? [])], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - } catch (ex) { - log?.error('Error running pip list', ex); - log?.info( - 'Package list retrieval attempted using pip, action can be done with uv if installed and setting `alwaysUseUv` is enabled.', - ); - throw ex; - } -} - -export async function refreshPipPackages( - environment: PythonEnvironment, - log?: LogOutputChannel, - options?: { showProgress: boolean }, -): Promise { - let data: string; - try { - if (options?.showProgress) { - data = await withProgress( - { - location: ProgressLocation.Notification, - }, - async () => { - return await execPipList(environment, log); - }, - ); - } else { - data = await execPipList(environment, log); - } - - return parsePipListJson(data, log); - } catch (e) { - log?.error('Error refreshing packages', e); - showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); - return undefined; - } -} - -/** - * Returns names of packages with no installed dependents (leaf packages). - * - * Uses `pip list --not-required` (pip) or `uv pip tree --depth=0` (uv). These report - * packages that nothing else depends on, which is a proxy for "directly installed" but - * not equivalent — e.g., `pip install flask werkzeug` will report werkzeug as having - * dependents (flask) even though the user installed it explicitly. - */ -export async function refreshPipDirectPackageNames( - environment: PythonEnvironment, - log?: LogOutputChannel, -): Promise { - const useUv = await shouldUseUv(log, environment.environmentPath.fsPath); - if (useUv) { - const treeOutput = await runUV( - ['pip', 'tree', '--python', environment.execInfo.run.executable, '--depth=0'], - undefined, - log, - undefined, - PIP_LIST_TIMEOUT_MS, - ); - return parseUvTree(treeOutput); - } - const data = await execPipList(environment, log, ['--not-required']); - const packages = parsePipListJson(data); - return packages.map((pkg) => pkg.name); -} - -export async function managePackages( - environment: PythonEnvironment, - options: PackageManagementOptions, - manager: PackageManager, - token?: CancellationToken, -): Promise { - if (environment.version.startsWith('2.')) { - throw new Error('Python 2.* is not supported (deprecated)'); - } - - // Use environmentPath directly for consistency with UV environment tracking - const useUv = await shouldUseUv(manager.log, environment.environmentPath.fsPath); - const uninstallArgs = ['pip', 'uninstall']; - if (options.uninstall && options.uninstall.length > 0) { - if (useUv) { - await runUV( - [...uninstallArgs, '--python', environment.execInfo.run.executable, ...options.uninstall], - undefined, - manager.log, - token, - ); - } else { - uninstallArgs.push('--yes'); - await runPython( - environment.execInfo.run.executable, - ['-m', ...uninstallArgs, ...options.uninstall], - undefined, - manager.log, - token, - ); - } - } - - const installArgs = ['pip', 'install']; - if (options.upgrade) { - installArgs.push('--upgrade'); - } - if (options.install && options.install.length > 0) { - const processedInstallArgs = processEditableInstallArgs(options.install); - - if (useUv) { - await runUV( - [...installArgs, '--python', environment.execInfo.run.executable, ...processedInstallArgs], - undefined, - manager.log, - token, - ); - } else { - await runPython( - environment.execInfo.run.executable, - ['-m', ...installArgs, ...processedInstallArgs], - undefined, - manager.log, - token, - ); - } - } -} - /** * Process pip install arguments to correctly handle editable installs with extras * This function will combine consecutive -e arguments that represent the same package with extras diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 4105800ba..ccbd36ff3 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, explain as parse } 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 { parsePackageSpecs } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; -import { getCommonCondaPackagesToInstall, managePackages, runCondaExecutable } from './condaUtils'; +import { + CondaAvailableVersionsCommand, + CondaInstallCommand, + CondaListCommand, + CondaUninstallCommand, + CondaVersionCommand, +} from './commands/index'; +import { getCommonCondaPackagesToInstall } from './condaUtils'; export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -63,40 +71,59 @@ export class CondaPackageManager implements PackageManager, Disposable { } } - const manageOptions = { - ...options, - install: toInstall, - uninstall: toUninstall, - }; - await withProgress( - { - location: ProgressLocation.Notification, - title: CondaStrings.condaInstallingPackages, - cancellable: true, - }, - async (_progress, token) => { - try { - await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - - this.log.error('Error installing packages', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); - } - }, - ); + try { + // Centralize command options for install/uninstall operations + const environmentPath = environment.environmentPath.fsPath; + + // Execute uninstall if needed + if (toUninstall.length > 0) { + const uninstallCmd = new CondaUninstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: this.log, + }); + const packages = parsePackageSpecs(toUninstall); + await withProgress( + { + location: ProgressLocation.Notification, + title: CondaStrings.condaInstallingPackages, + cancellable: true, + }, + (_progress, token) => uninstallCmd.execute({ packages, cancellationToken: token }), + ); + } + + // Execute install if needed + if (toInstall.length > 0) { + const installCmd = new CondaInstallCommand({ + pythonExecutable: 'conda', + condaEnvironmentPath: environmentPath, + log: this.log, + }); + const packages = parsePackageSpecs(toInstall); + await withProgress( + { + location: ProgressLocation.Notification, + title: CondaStrings.condaInstallingPackages, + cancellable: true, + }, + (_progress, token) => + installCmd.execute({ packages, upgrade: options.upgrade, cancellationToken: token }), + ); + } + + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + if (e instanceof CancellationError) { + throw e; + } + this.log.error('Error installing packages', e); + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } } async refresh(environment: PythonEnvironment): Promise { @@ -120,34 +147,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; } @@ -161,10 +167,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; } @@ -175,25 +182,15 @@ export class CondaPackageManager implements PackageManager, Disposable { packageName: string, ): Promise { try { - const output = await runCondaExecutable(['search', packageName, '--json'], this.log); - const parsed = JSON.parse(output); - if (parsed && typeof parsed === 'object' && Array.isArray(parsed[packageName])) { - const uniqueVersions = new Map(); - parsed[packageName] - .filter((entry: { version?: string }) => !!entry.version?.trim()) - .map((entry: { version?: string }) => parse(entry.version!)) - .filter((v: Pep440Version | null): v is Pep440Version => v !== null) - .forEach((version: Pep440Version) => { - if (!uniqueVersions.has(version.public)) { - uniqueVersions.set(version.public, version); - } - }); - - return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => - rcompare(a.public, b.public), - ); - } - return undefined; + const availableVersionsCmd = new CondaAvailableVersionsCommand({ + pythonExecutable: 'conda', + log: this.log, + }); + const versionStrings = await availableVersionsCmd.execute({ packageName, pythonVersion: '' }); + return versionStrings + .map((v) => parse(v)) + .filter((parsed): parsed is Pep440Version => parsed !== null) + .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/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 54845a20b..85a8a15a7 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -1,10 +1,8 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { explain as parse } from '@renovatebot/pep440'; import * as fsapi from 'fs-extra'; import * as path from 'path'; import { CancellationError, - CancellationToken, Event, EventEmitter, l10n, @@ -24,12 +22,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 { parsePackageSpecs } from '../builtin/utils'; import { updatePackagesAndNotify } from '../common/packageChanges'; +import { + PoetryAddCommand, + PoetryRemoveCommand, + PoetryShowCommand, + PoetryShowTopLevelCommand, + PoetryVersionCommand, +} from './commands/index'; import { PoetryManager } from './poetryManager'; -import { getPoetry, getPoetryVersion } from './poetryUtils'; +import { getPoetry } from './poetryUtils'; export class PoetryPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -77,38 +81,25 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - await withProgress( - { - location: ProgressLocation.Notification, - title: 'Managing packages with Poetry', - cancellable: true, - }, - async (_progress, token) => { - try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); - } catch (e) { - if (e instanceof CancellationError) { - throw e; - } - this.log.error('Error managing packages with Poetry', e); - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); - throw e; + try { + await this.runPoetryManage(environment, { install: toInstall, uninstall: toUninstall }); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } catch (e) { + if (e instanceof CancellationError) { + // Cancellation is not an error; rethrow without surfacing an error message. + throw e; + } + this.log.error('Error managing packages with Poetry', e); + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); } - }, - ); + }); + throw e; + } } async refresh(environment: PythonEnvironment): Promise { @@ -156,8 +147,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( @@ -181,8 +175,8 @@ export class PoetryPackageManager implements PackageManager, Disposable { } private async runPoetryManage( + environment: PythonEnvironment, options: { install?: string[]; uninstall?: string[] }, - token?: CancellationToken, ): Promise { const poetry = await getPoetry(); if (!poetry) { @@ -192,31 +186,34 @@ export class PoetryPackageManager implements PackageManager, Disposable { ), ); } + const cwd = await this.getPoetryCwd(environment); // 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, + cwd, + log: this.log, + }); + const packages = parsePackageSpecs(options.uninstall); + await withProgress( + { location: ProgressLocation.Notification, title: 'Managing packages with Poetry', cancellable: true }, + (_progress, token) => 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, + cwd, + log: this.log, + }); + const packages = parsePackageSpecs(options.install); + await withProgress( + { location: ProgressLocation.Notification, title: 'Managing packages with Poetry', cancellable: true }, + (_progress, token) => addCmd.execute({ packages, cancellationToken: token }), + ); } } @@ -230,126 +227,64 @@ 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, + }); + const data = await showCmd.execute(); + return (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); + } + async getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { 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() || ''}`, - }); - } + const poetry = await getPoetry(); + if (!poetry) { + return undefined; } + const cwd = await this.getPoetryCwd(environment); + const showTopLevelCmd = new PoetryShowTopLevelCommand({ + pythonExecutable: poetry, + cwd, + log: this.log, + }); + return await showTopLevelCmd.execute(); } catch (err) { - this.log.error(`Error refreshing packages with Poetry: ${err}`); - // Return empty array instead of throwing to avoid breaking the UI - return []; + this.log.error(`Error fetching direct package names with Poetry: ${err}`); + return undefined; } - - // 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); - } catch (err) { - this.log.error(`Error fetching direct package names with Poetry: ${err}`); + private async getPoetryCwd(environment: PythonEnvironment): Promise { + const projects = this.api.getPythonProjects(); + if (projects.length === 0) { 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'); - } + 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); + } + }; - log?.info(`Running: ${poetry} ${args.join(' ')}`); + if (projects.length === 1) { + return toDirectory(projects[0].uri.fsPath); + } - 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 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/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/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts deleted file mode 100644 index 5c06c394b..000000000 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'assert'; -import { explain } from '@renovatebot/pep440'; -import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipPackageManager'; - -suite('Pip Version Parsing', () => { - suite('parsePipIndexVersionsJson', () => { - test('parses valid JSON with versions array', () => { - const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); - const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); - }); - - test('parses output with a single version', () => { - const output = JSON.stringify({ name: 'my-package', versions: ['1.0.0'] }); - const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, [explain('1.0.0')]); - }); - - test('returns undefined for empty versions array', () => { - const output = JSON.stringify({ name: 'pkg', versions: [] }); - const versions = parsePipIndexVersionsJson(output); - assert.strictEqual(versions, undefined); - }); - - test('returns undefined for invalid JSON', () => { - const versions = parsePipIndexVersionsJson('not json'); - assert.strictEqual(versions, undefined); - }); - - test('returns undefined when versions field is missing', () => { - const output = JSON.stringify({ name: 'pkg' }); - const versions = parsePipIndexVersionsJson(output); - assert.strictEqual(versions, undefined); - }); - }); -}); - From a135bc8e0c7de741415b8babb24e068387991673 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 28 Jul 2026 14:51:18 -0700 Subject: [PATCH 3/6] Parse pep440 versions --- src/managers/builtin/pipPackageManager.ts | 7 ++----- src/managers/conda/condaPackageManager.ts | 9 +++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 36d4e5968..efdb82d73 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -239,14 +239,11 @@ export class PipPackageManager implements PackageManager, Disposable { } } - const versionStrings = await availableVersionsCmd.execute({ + const versions = await availableVersionsCmd.execute({ packageName, pythonVersion: baseVersion, }); - return versionStrings - .map((v) => parse(v)) - .filter((parsed): parsed is Pep440Version => parsed !== null) - .sort((a, b) => compare(b.public, a.public)); + return versions.sort((a, b) => compare(b.public, a.public)); } catch { return undefined; } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index ccbd36ff3..df4c305d2 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,5 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { compare, explain as parse } from '@renovatebot/pep440'; +import { compare } from '@renovatebot/pep440'; import * as path from 'path'; import { CancellationError, @@ -186,11 +186,8 @@ export class CondaPackageManager implements PackageManager, Disposable { pythonExecutable: 'conda', log: this.log, }); - const versionStrings = await availableVersionsCmd.execute({ packageName, pythonVersion: '' }); - return versionStrings - .map((v) => parse(v)) - .filter((parsed): parsed is Pep440Version => parsed !== null) - .sort((a, b) => compare(b.public, a.public)); + const versions = await availableVersionsCmd.execute({ packageName, pythonVersion: '' }); + return versions.sort((a, b) => compare(b.public, a.public)); } catch { return undefined; } From e292a5dfc97a03ef6cc9b80e9acfa9b6fb8d0785 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 31 Jul 2026 15:08:08 -0700 Subject: [PATCH 4/6] Address #1686 review comments - pipPackageManager.manage(): rethrow CancellationError instead of swallowing it, so callers (e.g. venv creation's pkgInstallationCancelled) can distinguish cancel from failure; restores parity with main and the conda/poetry managers. - getDirectPackageNames: fix docstring to reflect uv uses 'uv pip tree --depth=0' (not 'uv pip list --not-required'). - parsePackageSpecs: omit undefined version property and clarify wording. - runPython: restore 'python:' prefix on stderr log output for consistency. - Remove overlapping command smoke tests; the reworked, parsed-value versions are owned by the tests PR (#1677). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f16397e-0917-4efb-8d75-566c71ebf9ba --- src/managers/builtin/helpers.ts | 2 +- src/managers/builtin/pipPackageManager.ts | 15 ++- src/managers/builtin/utils.ts | 4 +- .../managers/builtin/commands.unit.test.ts | 111 ------------------ src/test/managers/conda/commands.unit.test.ts | 73 ------------ .../managers/poetry/commands.unit.test.ts | 62 ---------- 6 files changed, 13 insertions(+), 254 deletions(-) delete mode 100644 src/test/managers/builtin/commands.unit.test.ts delete mode 100644 src/test/managers/conda/commands.unit.test.ts delete mode 100644 src/test/managers/poetry/commands.unit.test.ts diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 666775163..7fb7062a2 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -153,7 +153,7 @@ export async function runPython( proc.stderr?.on('data', (data) => { const s = data.toString('utf-8'); builder += s; - log?.append(s); + log?.append(`python: ${s}`); }); proc.on('close', () => { resolve(builder); diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index efdb82d73..e0cd09852 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -129,8 +129,10 @@ export class PipPackageManager implements PackageManager, Disposable { }); } catch (e) { if (e instanceof CancellationError) { - // Cancellation is a normal control-flow exit; do not surface an error. - return; + // Cancellation is a normal control-flow exit; skip the user-facing error + // UI/logging, but rethrow so callers can distinguish cancel from failure + // (e.g. venv creation sets pkgInstallationCancelled by catching CancellationError). + throw e; } this.log.error('Error managing packages', e); setImmediate(async () => { @@ -255,10 +257,13 @@ export class PipPackageManager implements PackageManager, Disposable { } /** - * Returns direct (non-transitive) package names using `pip list --not-required` or `uv pip list --not-required`. + * Returns direct (non-transitive) package names. + * + * pip uses `pip list --format=json --not-required`; uv uses `uv pip tree --depth=0`. * - * Note: These commands return packages with no installed dependents (leaf packages), not packages - * the user explicitly installed. pip/uv do not track install intent. + * 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 pythonExecutable = environment.execInfo?.run?.executable; diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 046348035..673209443 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -20,12 +20,12 @@ let pixiRecommendationShown = false; /** * Parse package specifications (strings) into package objects. - * Each string becomes a package object with packageName and empty version. + * Each string becomes a package object with its packageName; the version is left + * unset (the spec string may itself embed a version, e.g. "requests==2.31.0"). */ export function parsePackageSpecs(packageStrings: string[]): { packageName: string; version?: string }[] { return packageStrings.map((pkg) => ({ packageName: pkg, - version: undefined, })); } diff --git a/src/test/managers/builtin/commands.unit.test.ts b/src/test/managers/builtin/commands.unit.test.ts deleted file mode 100644 index 808203731..000000000 --- a/src/test/managers/builtin/commands.unit.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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('PipAvailableVersionsCommand executes without error', async () => { - runPythonStub.resolves(JSON.stringify({ versions: ['1.0.0'] })); - const command = new PipAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '3.13.1' })); - }); - - test('UvAvailableVersionsCommand executes without error', async () => { - runUvStub.resolves(JSON.stringify({ versions: ['1.0.0'] })); - const command = new UvAvailableVersionsCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '3.13.1' })); - }); - - test('PipInstallCommand executes without error', async () => { - const command = new PipInstallCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('UvInstallCommand executes without error', async () => { - const command = new UvInstallCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('PipUninstallCommand executes without error', async () => { - const command = new PipUninstallCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('UvUninstallCommand executes without error', async () => { - const command = new UvUninstallCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('PipListCommand executes without error', async () => { - runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); - const command = new PipListCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('UvListCommand executes without error', async () => { - runUvStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); - const command = new UvListCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('PipListDirectNamesCommand executes without error', async () => { - runPythonStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); - const command = new PipListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('UvListDirectNamesCommand executes without error', async () => { - runUvStub.resolves('package 1.0.0'); - const command = new UvListDirectNamesCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('PipVersionCommand executes without error', async () => { - runPythonStub.resolves('pip 24.0 from site-packages'); - const command = new PipVersionCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('UvVersionCommand executes without error', async () => { - runUvStub.resolves('uv 0.4.20'); - const command = new UvVersionCommand({ pythonExecutable: 'python', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); -}); \ No newline at end of file diff --git a/src/test/managers/conda/commands.unit.test.ts b/src/test/managers/conda/commands.unit.test.ts deleted file mode 100644 index 8b1d11f48..000000000 --- a/src/test/managers/conda/commands.unit.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from 'assert'; -import * as sinon from 'sinon'; -import { LogOutputChannel } 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 mockLog: LogOutputChannel; - let runCondaStub: sinon.SinonStub; - - setup(() => { - mockLog = createMockLogOutputChannel(); - sinon.stub(workspaceApis, 'getConfiguration').returns({ - get: () => undefined, - } as unknown as ReturnType); - runCondaStub = sinon.stub(condaUtils, 'runCondaExecutable').resolves(''); - }); - - teardown(() => { - sinon.restore(); - }); - - test('CondaAvailableVersionsCommand executes without error', async () => { - runCondaStub.resolves(JSON.stringify({ package: [{ version: '1.0.0' }] })); - const command = new CondaAvailableVersionsCommand({ pythonExecutable: 'conda', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packageName: 'package', pythonVersion: '' })); - }); - - test('CondaInstallCommand executes without error', async () => { - const command = new CondaInstallCommand({ - pythonExecutable: 'conda', - condaEnvironmentPath: 'environment', - log: mockLog, - }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('CondaUninstallCommand executes without error', async () => { - const command = new CondaUninstallCommand({ - pythonExecutable: 'conda', - condaEnvironmentPath: 'environment', - log: mockLog, - }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('CondaListCommand executes without error', async () => { - runCondaStub.resolves(JSON.stringify([{ name: 'package', version: '1.0.0' }])); - const command = new CondaListCommand({ - pythonExecutable: 'conda', - condaEnvironmentPath: 'environment', - log: mockLog, - }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('CondaVersionCommand executes without error', async () => { - runCondaStub.resolves('conda 24.1.2'); - const command = new CondaVersionCommand({ pythonExecutable: 'conda', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); -}); \ No newline at end of file diff --git a/src/test/managers/poetry/commands.unit.test.ts b/src/test/managers/poetry/commands.unit.test.ts deleted file mode 100644 index 4deb7b058..000000000 --- a/src/test/managers/poetry/commands.unit.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from 'assert'; -import * as sinon from 'sinon'; -import { LogOutputChannel } 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 mockLog: LogOutputChannel; - let runPoetryStub: sinon.SinonStub; - - setup(() => { - 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 executes without error', async () => { - const command = new PoetryAddCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('PoetryRemoveCommand executes without error', async () => { - const command = new PoetryRemoveCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); - - await assert.doesNotReject(() => command.execute({ packages: [{ packageName: 'package' }] })); - }); - - test('PoetryShowCommand executes without error', async () => { - runPoetryStub.resolves('package 1.0.0 description'); - const command = new PoetryShowCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('PoetryShowTopLevelCommand executes without error', async () => { - runPoetryStub.resolves('package'); - const command = new PoetryShowTopLevelCommand({ pythonExecutable: 'poetry', cwd: 'project', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); - - test('PoetryVersionCommand executes without error', async () => { - const command = new PoetryVersionCommand({ pythonExecutable: 'poetry', log: mockLog }); - - await assert.doesNotReject(() => command.execute()); - }); -}); \ No newline at end of file From d82423c254019387fc74f62915d35566973b73f3 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 31 Jul 2026 15:10:58 -0700 Subject: [PATCH 5/6] Add parametrized package manager roundtrip integration test Public-API-driven integration test exercising install/list/direct-deps/uninstall for every discovered package manager, parametrized over environments grouped by managerId so future managers are covered automatically. Consolidated into this PR per review preference rather than a separate PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f16397e-0917-4efb-8d75-566c71ebf9ba --- ...ackageManagerRoundtrip.integration.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/test/integration/packageManagerRoundtrip.integration.test.ts diff --git a/src/test/integration/packageManagerRoundtrip.integration.test.ts b/src/test/integration/packageManagerRoundtrip.integration.test.ts new file mode 100644 index 000000000..5a16e959f --- /dev/null +++ b/src/test/integration/packageManagerRoundtrip.integration.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Integration Test: Package Manager Roundtrip + * + * PURPOSE: + * Verify that every discovered package manager performs a full package lifecycle + * correctly, using only the public extension API. The test is parametrized over + * the package managers backing the discovered environments, so new managers are + * covered automatically without adding manager-specific code. + * + * ROUNDTRIP (per manager, all via the public API): + * 1. list packages -> getPackages (baseline; test package absent) + * 2. install test package -> managePackages({ install }) + * 3. list again -> getPackages (test package now present) + * 4. list direct packages -> getPackages filtered by !isTransitive + * 5. uninstall test package -> managePackages({ uninstall }) + * 6. list again -> getPackages (test package absent again) + * + * NOTES: + * - "Available versions" is intentionally NOT exercised here: the exported + * PythonEnvironmentApi does not surface getPackageAvailableVersions (it lives + * only on the internal PackageManager interface), so it is unreachable from a + * public-API-only test. + * - Direct (non-transitive) packages are derived from Package.isTransitive, + * which IS part of the public API. + * - The test package (cowsay) is small and dependency-free so a successful + * install shows up as a direct package with no transitive fan-out. + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { Package, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { normalizePackageName } from '../../managers/builtin/utils'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +/** Small, dependency-free package used for the install/uninstall roundtrip. */ +const TEST_PACKAGE = 'cowsay'; + +/** True when a package with the given (normalized) name is in the list. */ +function hasPackage(packages: Package[] | undefined, name: string): boolean { + const target = normalizePackageName(name); + return (packages ?? []).some((p) => normalizePackageName(p.name) === target); +} + +/** + * Runs the full lifecycle roundtrip for a single environment/manager. + * Returns undefined on success, or a human-readable skip reason. + */ +async function runRoundtrip(api: PythonEnvironmentApi, env: PythonEnvironment, managerId: string): Promise { + const baseline = await api.getPackages(env, { skipCache: true }); + if (baseline === undefined) { + // No usable package manager for this environment. + return; + } + + // Avoid clobbering a pre-existing install of the test package. + assert.ok( + !hasPackage(baseline, TEST_PACKAGE), + `[${managerId}] ${TEST_PACKAGE} unexpectedly already installed; cannot run a clean roundtrip`, + ); + + let installed = false; + try { + // 2. Install. + await api.managePackages(env, { install: [TEST_PACKAGE] }); + installed = true; + + // 3. List again -> present. + await api.refreshPackages(env); + const afterInstall = await api.getPackages(env, { skipCache: true }); + assert.ok(hasPackage(afterInstall, TEST_PACKAGE), `[${managerId}] ${TEST_PACKAGE} should be installed`); + + // 4. Direct (non-transitive) packages should include the directly-installed package. + const direct = (afterInstall ?? []).filter((p) => p.isTransitive !== true); + assert.ok( + hasPackage(direct, TEST_PACKAGE), + `[${managerId}] ${TEST_PACKAGE} should be reported as a direct (non-transitive) package`, + ); + + // 5. Uninstall. + await api.managePackages(env, { uninstall: [TEST_PACKAGE] }); + installed = false; + + // 6. List again -> absent. + await api.refreshPackages(env); + const afterUninstall = await api.getPackages(env, { skipCache: true }); + assert.ok(!hasPackage(afterUninstall, TEST_PACKAGE), `[${managerId}] ${TEST_PACKAGE} should be uninstalled`); + } finally { + // Best-effort cleanup so a mid-roundtrip failure never leaves the env dirty. + if (installed) { + try { + await api.managePackages(env, { uninstall: [TEST_PACKAGE] }); + } catch { + console.log(`[${managerId}] cleanup: failed to uninstall ${TEST_PACKAGE}`); + } + } + } +} + +suite('Integration: Package Manager Roundtrip', function () { + this.timeout(180_000); // Install/uninstall across multiple managers can be slow. + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + this.timeout(30_000); + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate'); + } + + api = extension.exports as PythonEnvironmentApi; + assert.ok(api, 'API not available'); + }); + + /** + * Picks one representative environment per package manager, grouped by managerId. + * Prefers virtual-environment-like envs, which are safe to install into. + */ + async function getEnvironmentsByManager(): Promise> { + const environments = await api.getEnvironments('all'); + const byManager = new Map(); + + const looksModifiable = (env: PythonEnvironment): boolean => + env.displayName.includes('venv') || + env.displayName.includes('.venv') || + env.envId.managerId.includes('venv'); + + for (const env of environments) { + const managerId = env.envId.managerId; + const current = byManager.get(managerId); + if (!current || (looksModifiable(env) && !looksModifiable(current))) { + byManager.set(managerId, env); + } + } + return byManager; + } + + /** + * Parametrized roundtrip: one assertion pass per discovered package manager. + * + * This is a single test that iterates managers (rather than a static list) so + * that any future manager is exercised automatically once its environments are + * discovered. Failures are aggregated and reported per manager. + */ + test('install/list/direct/uninstall roundtrip for each package manager', async function () { + const byManager = await getEnvironmentsByManager(); + + if (byManager.size === 0) { + console.log('No environments discovered; skipping package manager roundtrip'); + this.skip(); + return; + } + + const failures: string[] = []; + let exercised = 0; + + for (const [managerId, env] of byManager) { + try { + const before = await api.getPackages(env, { skipCache: true }); + if (before === undefined) { + console.log(`[${managerId}] no package manager available; skipping`); + continue; + } + if (hasPackage(before, TEST_PACKAGE)) { + console.log(`[${managerId}] ${TEST_PACKAGE} already present; skipping to avoid clobbering`); + continue; + } + + await runRoundtrip(api, env, managerId); + exercised++; + console.log(`[${managerId}] roundtrip passed (${env.displayName})`); + } catch (e) { + failures.push(`[${managerId}] ${e instanceof Error ? e.message : String(e)}`); + } + } + + assert.strictEqual(failures.length, 0, `Package manager roundtrip failures:\n${failures.join('\n')}`); + + if (exercised === 0) { + console.log('No modifiable package managers were exercised; skipping'); + this.skip(); + } + }); +}); From dfa4f9a25d0809dd4d013e1f919c1ae12cdf23ab Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 31 Jul 2026 15:27:38 -0700 Subject: [PATCH 6/6] test: exercise getPackageAvailableVersions in roundtrip Add a capability-guarded available-versions step to the package manager roundtrip integration test. Uses an optional cast so it compiles and runs regardless of whether the active API build surfaces the getter, and skips the assertion for managers that resolve to undefined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f16397e-0917-4efb-8d75-566c71ebf9ba --- ...ackageManagerRoundtrip.integration.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/test/integration/packageManagerRoundtrip.integration.test.ts b/src/test/integration/packageManagerRoundtrip.integration.test.ts index 5a16e959f..89409393c 100644 --- a/src/test/integration/packageManagerRoundtrip.integration.test.ts +++ b/src/test/integration/packageManagerRoundtrip.integration.test.ts @@ -15,14 +15,16 @@ * 2. install test package -> managePackages({ install }) * 3. list again -> getPackages (test package now present) * 4. list direct packages -> getPackages filtered by !isTransitive + * 4b. available versions -> getPackageAvailableVersions (when supported) * 5. uninstall test package -> managePackages({ uninstall }) * 6. list again -> getPackages (test package absent again) * * NOTES: - * - "Available versions" is intentionally NOT exercised here: the exported - * PythonEnvironmentApi does not surface getPackageAvailableVersions (it lives - * only on the internal PackageManager interface), so it is unreachable from a - * public-API-only test. + * - "Available versions" is exercised via getPackageAvailableVersions when the + * running PythonEnvironmentApi surfaces it. That getter was added to the public + * API; the call is capability-guarded so this test compiles and runs regardless + * of whether the active API build includes it, and gracefully skips the check + * for managers that resolve to `undefined` (no version listing support). * - Direct (non-transitive) packages are derived from Package.isTransitive, * which IS part of the public API. * - The test package (cowsay) is small and dependency-free so a successful @@ -45,6 +47,18 @@ function hasPackage(packages: Package[] | undefined, name: string): boolean { return (packages ?? []).some((p) => normalizePackageName(p.name) === target); } +/** + * Optional API capability: some API builds surface available-version listing. + * Cast through this shape so the test compiles whether or not the running API + * exposes getPackageAvailableVersions. + */ +type AvailableVersionsCapable = { + getPackageAvailableVersions?: ( + environment: PythonEnvironment, + packageName: string, + ) => Promise; +}; + /** * Runs the full lifecycle roundtrip for a single environment/manager. * Returns undefined on success, or a human-readable skip reason. @@ -80,6 +94,19 @@ async function runRoundtrip(api: PythonEnvironmentApi, env: PythonEnvironment, m `[${managerId}] ${TEST_PACKAGE} should be reported as a direct (non-transitive) package`, ); + // 4b. Available versions (optional API capability). Only asserted when the + // running API surfaces the getter and the manager supports version listing. + const versionsApi = api as unknown as AvailableVersionsCapable; + if (typeof versionsApi.getPackageAvailableVersions === 'function') { + const versions = await versionsApi.getPackageAvailableVersions(env, TEST_PACKAGE); + if (versions !== undefined) { + assert.ok( + Array.isArray(versions) && versions.length > 0, + `[${managerId}] ${TEST_PACKAGE} should report at least one available version`, + ); + } + } + // 5. Uninstall. await api.managePackages(env, { uninstall: [TEST_PACKAGE] }); installed = false;