From 824b22d1e22b99ebdb8a659934745dae8afb7226 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 14:56:19 -0700 Subject: [PATCH 01/15] ref: extract shared method for package changes --- src/managers/builtin/pipManager.ts | 19 +++------------- src/managers/common/packageChanges.ts | 20 +++++++++++++++++ src/managers/conda/condaPackageManager.ts | 24 ++++++--------------- src/managers/poetry/poetryPackageManager.ts | 19 +++------------- 4 files changed, 33 insertions(+), 49 deletions(-) create mode 100644 src/managers/common/packageChanges.ts diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index 81d26ea0..0c74a13e 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -13,27 +13,16 @@ import { DidChangePackagesEventArgs, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { getPackageChanges } from '../common/packageChanges'; import { getWorkspacePackagesToInstall } from './pipUtils'; import { managePackages, refreshPackages } from './utils'; import { VenvManager } from './venvManager'; -function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { - const changes: { kind: PackageChangeKind; pkg: Package }[] = []; - before.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.remove, pkg }); - }); - after.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.add, pkg }); - }); - return changes; -} - export class PipPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); onDidChangePackages: Event = this._onDidChangePackages.event; @@ -85,9 +74,8 @@ export class PipPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const before = this.packages.get(environment.envId.id) ?? []; const after = await managePackages(environment, manageOptions, this.api, this, token); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); this._onDidChangePackages.fire({ environment, manager: this, changes }); } catch (e) { @@ -114,9 +102,8 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - const before = this.packages.get(environment.envId.id) ?? []; const after = await refreshPackages(environment, this.api, this); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); if (changes.length > 0) { this._onDidChangePackages.fire({ environment, manager: this, changes }); diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts new file mode 100644 index 00000000..ed1066ff --- /dev/null +++ b/src/managers/common/packageChanges.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; + +export async function getPackageChanges( + packageManager: PackageManager, + environment: PythonEnvironment, + after: Package[], +): Promise<{ kind: PackageChangeKind; pkg: Package }[]> { + const before = (await packageManager.getPackages(environment)) ?? []; + const changes: { kind: PackageChangeKind; pkg: Package }[] = []; + before.forEach((pkg) => { + changes.push({ kind: PackageChangeKind.remove, pkg }); + }); + after.forEach((pkg) => { + changes.push({ kind: PackageChangeKind.add, pkg }); + }); + return changes; +} diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index c012ea91..9034065d 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -11,7 +11,6 @@ import { DidChangePackagesEventArgs, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -20,26 +19,19 @@ import { import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { CondaStrings } from '../../common/localize'; import { withProgress } from '../../common/window.apis'; +import { getPackageChanges } from '../common/packageChanges'; import { getCommonCondaPackagesToInstall, managePackages, refreshPackages } from './condaUtils'; -function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { - const changes: { kind: PackageChangeKind; pkg: Package }[] = []; - before.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.remove, pkg }); - }); - after.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.add, pkg }); - }); - return changes; -} - export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); onDidChangePackages: Event = this._onDidChangePackages.event; private packages: Map = new Map(); - constructor(public readonly api: PythonEnvironmentApi, public readonly log: LogOutputChannel) { + constructor( + public readonly api: PythonEnvironmentApi, + public readonly log: LogOutputChannel, + ) { this.name = 'conda'; this.displayName = 'Conda'; this.description = CondaStrings.condaPackageMgr; @@ -78,9 +70,8 @@ export class CondaPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const before = this.packages.get(environment.envId.id) ?? []; const after = await managePackages(environment, manageOptions, this.api, this, token, this.log); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); this._onDidChangePackages.fire({ environment: environment, manager: this, changes }); } catch (e) { @@ -104,9 +95,8 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - const before = this.packages.get(environment.envId.id) ?? []; const after = await refreshPackages(environment, this.api, this); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); if (changes.length > 0) { this._onDidChangePackages.fire({ environment, manager: this, changes }); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 21d5fb82..4ca221db 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -16,7 +16,6 @@ import { DidChangePackagesEventArgs, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -24,20 +23,10 @@ import { } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; +import { getPackageChanges } from '../common/packageChanges'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; -function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { - const changes: { kind: PackageChangeKind; pkg: Package }[] = []; - before.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.remove, pkg }); - }); - after.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.add, pkg }); - }); - return changes; -} - export class PoetryPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); onDidChangePackages: Event = this._onDidChangePackages.event; @@ -92,13 +81,12 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const before = this.packages.get(environment.envId.id) ?? []; const after = await this.managePackages( environment, { install: toInstall, uninstall: toUninstall }, token, ); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); this._onDidChangePackages.fire({ environment, manager: this, changes }); } catch (e) { @@ -126,9 +114,8 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async () => { try { - const before = this.packages.get(environment.envId.id) ?? []; const after = await this.refreshPackages(environment); - const changes = getChanges(before, after); + const changes = await getPackageChanges(this, environment, after); this.packages.set(environment.envId.id, after); if (changes.length > 0) { this._onDidChangePackages.fire({ environment, manager: this, changes }); From 9d79f4b8b198938a06d7bd0e94ca26d0bc19888e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 15:18:02 -0700 Subject: [PATCH 02/15] ref: Extract updatePackagesAndNotify --- src/managers/builtin/pipManager.ts | 30 ++++++++++-------- src/managers/builtin/utils.ts | 14 +-------- src/managers/common/packageChanges.ts | 12 ++++++++ src/managers/conda/condaPackageManager.ts | 26 +++++++++------- src/managers/conda/condaUtils.ts | 4 +-- src/managers/poetry/poetryPackageManager.ts | 34 +++++++++------------ 6 files changed, 62 insertions(+), 58 deletions(-) diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index 0c74a13e..ee39d43d 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -18,9 +18,9 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; -import { getPackageChanges } from '../common/packageChanges'; +import { updatePackagesAndNotify } from '../common/packageChanges'; import { getWorkspacePackagesToInstall } from './pipUtils'; -import { managePackages, refreshPackages } from './utils'; +import { managePackages, refreshPipPackages } from './utils'; import { VenvManager } from './venvManager'; export class PipPackageManager implements PackageManager, Disposable { @@ -74,10 +74,8 @@ export class PipPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const after = await managePackages(environment, manageOptions, this.api, this, token); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment, manager: this, changes }); + await managePackages(environment, manageOptions, this, token); + await this.updatePackagesAndNotify(environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -102,15 +100,11 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - const after = await refreshPackages(environment, this.api, this); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } + await this.updatePackagesAndNotify(environment); }, ); } + async getPackages(environment: PythonEnvironment): Promise { if (!this.packages.has(environment.envId.id)) { await this.refresh(environment); @@ -122,4 +116,16 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.dispose(); this.packages.clear(); } + + async fetchPackages(environment: PythonEnvironment): Promise { + const data = await refreshPipPackages(environment, this.log); + return (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); + } + + private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { + await updatePackagesAndNotify(this, environment, (after, changes) => { + this.packages.set(environment.envId.id, after); + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 3bab6770..6877f72d 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -243,22 +243,12 @@ export async function refreshPipPackages( } } -export async function refreshPackages( - environment: PythonEnvironment, - api: PythonEnvironmentApi, - manager: PackageManager, -): Promise { - const data = await refreshPipPackages(environment, manager.log); - return (data ?? []).map((pkg) => api.createPackageItem(pkg, environment, manager)); -} - export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, - api: PythonEnvironmentApi, manager: PackageManager, token?: CancellationToken, -): Promise { +): Promise { if (environment.version.startsWith('2.')) { throw new Error('Python 2.* is not supported (deprecated)'); } @@ -310,8 +300,6 @@ export async function managePackages( ); } } - - return await refreshPackages(environment, api, manager); } /** diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index ed1066ff..ea1de8c9 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -18,3 +18,15 @@ export async function getPackageChanges( }); return changes; } + +export async function updatePackagesAndNotify( + packageManager: PackageManager & { fetchPackages(environment: PythonEnvironment): Promise }, + environment: PythonEnvironment, + onChanged: (after: Package[], changes: { kind: PackageChangeKind; pkg: Package }[]) => void, +): Promise { + const after = await packageManager.fetchPackages(environment); + const changes = await getPackageChanges(packageManager, environment, after); + if (changes.length > 0) { + onChanged(after, changes); + } +} diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 9034065d..3398f8a6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -19,7 +19,7 @@ import { import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { CondaStrings } from '../../common/localize'; import { withProgress } from '../../common/window.apis'; -import { getPackageChanges } from '../common/packageChanges'; +import { updatePackagesAndNotify } from '../common/packageChanges'; import { getCommonCondaPackagesToInstall, managePackages, refreshPackages } from './condaUtils'; export class CondaPackageManager implements PackageManager, Disposable { @@ -70,10 +70,8 @@ export class CondaPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const after = await managePackages(environment, manageOptions, this.api, this, token, this.log); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment: environment, manager: this, changes }); + await managePackages(environment, manageOptions, this, token, this.log); + await this.updatePackagesAndNotify(environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -95,12 +93,7 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - const after = await refreshPackages(environment, this.api, this); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } + await this.updatePackagesAndNotify(environment); }, ); } @@ -116,4 +109,15 @@ export class CondaPackageManager implements PackageManager, Disposable { this._onDidChangePackages.dispose(); this.packages.clear(); } + + async fetchPackages(environment: PythonEnvironment): Promise { + return refreshPackages(environment, this.api, this); + } + + private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { + await updatePackagesAndNotify(this, environment, (after, changes) => { + this.packages.set(environment.envId.id, after); + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 51b1988e..3b725d31 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1333,11 +1333,10 @@ export async function refreshPackages( export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, - api: PythonEnvironmentApi, manager: PackageManager, token: CancellationToken, log: LogOutputChannel, -): Promise { +): Promise { if (options.uninstall && options.uninstall.length > 0) { await runCondaExecutable( ['remove', '--prefix', environment.environmentPath.fsPath, '--yes', ...options.uninstall], @@ -1353,7 +1352,6 @@ export async function managePackages( args.push(...options.install); await runCondaExecutable(args, log, token); } - return refreshPackages(environment, api, manager); } async function getCommonPackages(): Promise { diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 4ca221db..5f3caf58 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -23,7 +23,7 @@ import { } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; -import { getPackageChanges } from '../common/packageChanges'; +import { updatePackagesAndNotify } from '../common/packageChanges'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; @@ -81,14 +81,8 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - const after = await this.managePackages( - environment, - { install: toInstall, uninstall: toUninstall }, - token, - ); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment, manager: this, changes }); + await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); + await this.updatePackagesAndNotify(environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -114,12 +108,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async () => { try { - const after = await this.refreshPackages(environment); - const changes = await getPackageChanges(this, environment, after); - this.packages.set(environment.envId.id, after); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } + await this.updatePackagesAndNotify(environment); } catch (error) { this.log.error(`Failed to refresh packages: ${error}`); // Show error to user but don't break the UI @@ -146,11 +135,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { this.packages.clear(); } - private async managePackages( - environment: PythonEnvironment, + private async runPoetryManage( options: { install?: string[]; uninstall?: string[] }, token?: CancellationToken, - ): Promise { + ): Promise { const poetry = await getPoetry(); if (!poetry) { throw new Error( @@ -185,11 +173,19 @@ export class PoetryPackageManager implements PackageManager, Disposable { throw err; } } + } - // Refresh the packages list after changes + async fetchPackages(environment: PythonEnvironment): Promise { return this.refreshPackages(environment); } + private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { + await updatePackagesAndNotify(this, environment, (after, changes) => { + this.packages.set(environment.envId.id, after); + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); + } + private async refreshPackages(environment: PythonEnvironment): Promise { const poetry = await getPoetry(); if (!poetry) { From 36e85da4eebf49affc2739afd070e863c12f30b7 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 15:24:21 -0700 Subject: [PATCH 03/15] ref: Streamline refreshPackages --- api/src/main.ts | 8 ++++ examples/sample1/src/api.ts | 22 ++++++--- src/api.ts | 8 ++++ src/managers/common/packageChanges.ts | 2 +- src/managers/conda/condaPackageManager.ts | 33 ++++++++++++- src/managers/conda/condaUtils.ts | 52 --------------------- src/managers/poetry/poetryPackageManager.ts | 6 +-- 7 files changed, 64 insertions(+), 67 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index 32cc9ff9..762ae478 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -681,6 +681,14 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment): Promise; + /** + * Fetches the latest list of packages from the package manager for the specified Python environment. + * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. + * @param environment - The Python environment for which to fetch packages. + * @returns A promise that resolves to an array of packages. + */ + fetchPackages(environment: PythonEnvironment): Promise; + /** * Event that is fired when packages change. */ diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index 220cb947..333c3e69 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -615,6 +615,14 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment): Promise; + /** + * Fetches the latest list of packages from the package manager for the specified Python environment. + * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. + * @param environment - The Python environment for which to fetch packages. + * @returns A promise that resolves to an array of packages. + */ + fetchPackages(environment: PythonEnvironment): Promise; + /** * Event that is fired when packages change. */ @@ -915,7 +923,8 @@ export interface PythonProjectEnvironmentApi { } export interface PythonEnvironmentManagerApi - extends PythonEnvironmentManagerRegistrationApi, + extends + PythonEnvironmentManagerRegistrationApi, PythonEnvironmentItemApi, PythonEnvironmentManagementApi, PythonEnvironmentsApi, @@ -984,7 +993,8 @@ export interface PythonPackageManagementApi { } export interface PythonPackageManagerApi - extends PythonPackageManagerRegistrationApi, + extends + PythonPackageManagerRegistrationApi, PythonPackageGetterApi, PythonPackageManagementApi, PythonPackageItemApi {} @@ -1203,10 +1213,7 @@ export interface PythonBackgroundRunApi { } export interface PythonExecutionApi - extends PythonTerminalCreateApi, - PythonTerminalRunApi, - PythonTaskRunApi, - PythonBackgroundRunApi {} + extends PythonTerminalCreateApi, PythonTerminalRunApi, PythonTaskRunApi, PythonBackgroundRunApi {} /** * Event arguments for when the monitored `.env` files or any other sources change. @@ -1255,7 +1262,8 @@ export interface PythonEnvironmentVariablesApi { * The API for interacting with Python environments, package managers, and projects. */ export interface PythonEnvironmentApi - extends PythonEnvironmentManagerApi, + extends + PythonEnvironmentManagerApi, PythonPackageManagerApi, PythonProjectApi, PythonExecutionApi, diff --git a/src/api.ts b/src/api.ts index b641ad3f..00353da8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -675,6 +675,14 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment): Promise; + /** + * Fetches the latest list of packages from the package manager for the specified Python environment. + * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. + * @param environment - The Python environment for which to fetch packages. + * @returns A promise that resolves to an array of packages. + */ + fetchPackages(environment: PythonEnvironment): Promise; + /** * Event that is fired when packages change. */ diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index ea1de8c9..8e326a95 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -20,7 +20,7 @@ export async function getPackageChanges( } export async function updatePackagesAndNotify( - packageManager: PackageManager & { fetchPackages(environment: PythonEnvironment): Promise }, + packageManager: PackageManager, environment: PythonEnvironment, onChanged: (after: Package[], changes: { kind: PackageChangeKind; pkg: Package }[]) => void, ): Promise { diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 3398f8a6..021efd39 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -18,9 +18,10 @@ 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, refreshPackages } from './condaUtils'; +import { getCommonCondaPackagesToInstall, managePackages, runCondaExecutable } from './condaUtils'; export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -111,7 +112,35 @@ export class CondaPackageManager implements PackageManager, Disposable { } async fetchPackages(environment: PythonEnvironment): Promise { - return refreshPackages(environment, this.api, this); + 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, + ), + ); + } + } + return packages; } private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 3b725d31..d7d789b0 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -15,7 +15,6 @@ import { import which from 'which'; import { EnvironmentManager, - Package, PackageManagementOptions, PackageManager, PythonCommandRunConfiguration, @@ -1279,57 +1278,6 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log /** * JSON structure returned by `conda list --json` */ -interface CondaPackageJson { - name: string; - version: string; - build_string?: string; - channel?: string; -} - -/** - * Refreshes the list of packages installed in a conda environment. - * Uses `conda list -p --json` for reliable parsing. - * - * @param environment The Python environment to get packages for - * @param api The Python environment API - * @param manager The package manager instance - * @returns Promise resolving to an array of Package objects - */ -export async function refreshPackages( - environment: PythonEnvironment, - api: PythonEnvironmentApi, - manager: PackageManager, -): Promise { - const args = ['list', '-p', environment.environmentPath.fsPath, '--json']; - const data = await runCondaExecutable(args); - - let condaPackages: CondaPackageJson[]; - try { - condaPackages = JSON.parse(data) as CondaPackageJson[]; - } 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) { - const pkg = api.createPackageItem( - { - name: condaPkg.name, - displayName: condaPkg.name, - version: condaPkg.version, - description: condaPkg.version, - }, - environment, - manager, - ); - packages.push(pkg); - } - } - return packages; -} - export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 5f3caf58..83a1845f 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -175,10 +175,6 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - async fetchPackages(environment: PythonEnvironment): Promise { - return this.refreshPackages(environment); - } - private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { await updatePackagesAndNotify(this, environment, (after, changes) => { this.packages.set(environment.envId.id, after); @@ -186,7 +182,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { }); } - private async refreshPackages(environment: PythonEnvironment): Promise { + async fetchPackages(environment: PythonEnvironment): Promise { const poetry = await getPoetry(); if (!poetry) { throw new Error( From ebb16760fc3055b0a4aadc6ee55a1374c1d2a0e1 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 15:33:44 -0700 Subject: [PATCH 04/15] add: PackageManager::setPackages method --- api/src/main.ts | 12 ++++++++++++ examples/sample1/src/api.ts | 12 ++++++++++++ src/api.ts | 12 ++++++++++++ src/managers/builtin/pipManager.ts | 17 ++++++++++------- src/managers/common/packageChanges.ts | 3 +-- src/managers/conda/condaPackageManager.ts | 17 ++++++++++------- src/managers/poetry/poetryPackageManager.ts | 21 ++++++++++++--------- 7 files changed, 69 insertions(+), 25 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index 762ae478..7635f4f4 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -689,6 +689,18 @@ export interface PackageManager { */ fetchPackages(environment: PythonEnvironment): Promise; + /** + * Updates the cached packages for the specified environment and fires a change event. + * @param environment - The Python environment whose packages changed. + * @param packages - The new list of packages. + * @param changes - The list of changes describing what was added or removed. + */ + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void; + /** * Event that is fired when packages change. */ diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index 333c3e69..eb87337c 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -623,6 +623,18 @@ export interface PackageManager { */ fetchPackages(environment: PythonEnvironment): Promise; + /** + * Updates the cached packages for the specified environment and fires a change event. + * @param environment - The Python environment whose packages changed. + * @param packages - The new list of packages. + * @param changes - The list of changes describing what was added or removed. + */ + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void; + /** * Event that is fired when packages change. */ diff --git a/src/api.ts b/src/api.ts index 00353da8..c2c07545 100644 --- a/src/api.ts +++ b/src/api.ts @@ -683,6 +683,18 @@ export interface PackageManager { */ fetchPackages(environment: PythonEnvironment): Promise; + /** + * Updates the cached packages for the specified environment and fires a change event. + * @param environment - The Python environment whose packages changed. + * @param packages - The new list of packages. + * @param changes - The list of changes describing what was added or removed. + */ + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void; + /** * Event that is fired when packages change. */ diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index ee39d43d..feedbb3c 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -13,6 +13,7 @@ import { DidChangePackagesEventArgs, IconPath, Package, + PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -75,7 +76,7 @@ export class PipPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, this, token); - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -100,7 +101,7 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); }, ); } @@ -122,10 +123,12 @@ export class PipPackageManager implements PackageManager, Disposable { return (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); } - private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { - await updatePackagesAndNotify(this, environment, (after, changes) => { - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }); + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void { + this.packages.set(environment.envId.id, packages); + this._onDidChangePackages.fire({ environment, manager: this, changes }); } } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 8e326a95..3edd707d 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -22,11 +22,10 @@ export async function getPackageChanges( export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, - onChanged: (after: Package[], changes: { kind: PackageChangeKind; pkg: Package }[]) => void, ): Promise { const after = await packageManager.fetchPackages(environment); const changes = await getPackageChanges(packageManager, environment, after); if (changes.length > 0) { - onChanged(after, changes); + packageManager.setPackages(environment, after, changes); } } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 021efd39..999e6c77 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -11,6 +11,7 @@ import { DidChangePackagesEventArgs, IconPath, Package, + PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -72,7 +73,7 @@ export class CondaPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, this, token, this.log); - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -94,7 +95,7 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); }, ); } @@ -143,10 +144,12 @@ export class CondaPackageManager implements PackageManager, Disposable { return packages; } - private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { - await updatePackagesAndNotify(this, environment, (after, changes) => { - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }); + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void { + this.packages.set(environment.envId.id, packages); + this._onDidChangePackages.fire({ environment, manager: this, changes }); } } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 83a1845f..2b3e5ad7 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -16,6 +16,7 @@ import { DidChangePackagesEventArgs, IconPath, Package, + PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -82,7 +83,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -108,7 +109,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async () => { try { - await this.updatePackagesAndNotify(environment); + await updatePackagesAndNotify(this, environment); } catch (error) { this.log.error(`Failed to refresh packages: ${error}`); // Show error to user but don't break the UI @@ -175,13 +176,6 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - private async updatePackagesAndNotify(environment: PythonEnvironment): Promise { - await updatePackagesAndNotify(this, environment, (after, changes) => { - this.packages.set(environment.envId.id, after); - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }); - } - async fetchPackages(environment: PythonEnvironment): Promise { const poetry = await getPoetry(); if (!poetry) { @@ -253,6 +247,15 @@ export class PoetryPackageManager implements PackageManager, Disposable { // Convert to Package objects using the API return poetryPackages.map((pkg) => this.api.createPackageItem(pkg, environment, this)); } + + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void { + this.packages.set(environment.envId.id, packages); + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } } export async function runPoetry( From cca4a6af6234fb6e8d90f5758926eb1859addf81 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 15:41:13 -0700 Subject: [PATCH 05/15] Lint --- src/internal.api.ts | 12 ++++++++++++ src/managers/conda/condaPackageManager.ts | 2 +- src/managers/conda/condaUtils.ts | 2 -- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/internal.api.ts b/src/internal.api.ts index 3d896c0c..748dbb03 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -371,6 +371,18 @@ export class InternalPackageManager implements PackageManager { return this.manager.getPackages(environment); } + fetchPackages(environment: PythonEnvironment): Promise { + return this.manager.fetchPackages(environment); + } + + setPackages( + environment: PythonEnvironment, + packages: Package[], + changes: { kind: PackageChangeKind; pkg: Package }[], + ): void { + this.manager.setPackages(environment, packages, changes); + } + onDidChangePackages(handler: (e: DidChangePackagesEventArgs) => void): Disposable { return this.manager.onDidChangePackages ? this.manager.onDidChangePackages(handler) : new Disposable(() => {}); } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 999e6c77..83b012f5 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -72,7 +72,7 @@ export class CondaPackageManager implements PackageManager, Disposable { }, async (_progress, token) => { try { - await managePackages(environment, manageOptions, this, token, this.log); + await managePackages(environment, manageOptions, token, this.log); await updatePackagesAndNotify(this, environment); } catch (e) { if (e instanceof CancellationError) { diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index d7d789b0..6d4a207b 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -16,7 +16,6 @@ import which from 'which'; import { EnvironmentManager, PackageManagementOptions, - PackageManager, PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentApi, @@ -1281,7 +1280,6 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, - manager: PackageManager, token: CancellationToken, log: LogOutputChannel, ): Promise { From e093f6b532d8cfb6fffa4deb3f899c72fec67c09 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 15:49:22 -0700 Subject: [PATCH 06/15] Optimize getPackageChanges --- src/managers/common/packageChanges.ts | 20 +- .../common/packageChanges.unit.test.ts | 181 ++++++++++++++++++ 2 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 src/test/managers/common/packageChanges.unit.test.ts diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 3edd707d..0ee5a000 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -9,13 +9,21 @@ export async function getPackageChanges( after: Package[], ): Promise<{ kind: PackageChangeKind; pkg: Package }[]> { const before = (await packageManager.getPackages(environment)) ?? []; + const beforeSet = new Set(before.map(({ name, version }) => `${name}==${version}`)); + const afterSet = new Set(after.map(({ name, version }) => `${name}==${version}`)); const changes: { kind: PackageChangeKind; pkg: Package }[] = []; - before.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.remove, pkg }); - }); - after.forEach((pkg) => { - changes.push({ kind: PackageChangeKind.add, pkg }); - }); + + for (const pkg of after) { + if (!beforeSet.has(`${pkg.name}==${pkg.version}`)) { + changes.push({ kind: PackageChangeKind.add, pkg }); + } + } + for (const pkg of before) { + if (!afterSet.has(`${pkg.name}==${pkg.version}`)) { + changes.push({ kind: PackageChangeKind.remove, pkg }); + } + } + return changes; } diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts new file mode 100644 index 00000000..e200dd75 --- /dev/null +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../../api'; +import { getPackageChanges, updatePackagesAndNotify } from '../../../managers/common/packageChanges'; + +suite('packageChanges', () => { + let environment: PythonEnvironment; + let packageManager: PackageManager; + + let getPackagesStub: sinon.SinonStub; + let fetchPackagesStub: sinon.SinonStub; + let setPackagesSpy: sinon.SinonSpy; + + setup(() => { + environment = {} as PythonEnvironment; + getPackagesStub = sinon.stub(); + fetchPackagesStub = sinon.stub(); + setPackagesSpy = sinon.spy(); + + packageManager = { + name: 'test', + manage: sinon.stub(), + refresh: sinon.stub(), + getPackages: getPackagesStub, + fetchPackages: fetchPackagesStub, + setPackages: setPackagesSpy, + } as unknown as PackageManager; + }); + + teardown(() => { + sinon.restore(); + }); + + suite('getPackageChanges', () => { + test('returns empty array when before and after are identical', async () => { + const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; + getPackagesStub.resolves(pkgs); + + const changes = await getPackageChanges(packageManager, environment, pkgs); + + assert.strictEqual(changes.length, 0); + }); + + test('returns empty array when both before and after are empty', async () => { + getPackagesStub.resolves([]); + + const changes = await getPackageChanges(packageManager, environment, []); + + assert.strictEqual(changes.length, 0); + }); + + test('returns add changes for new packages', async () => { + getPackagesStub.resolves([]); + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'flask', version: '3.0.0' } as Package, + ]; + + const changes = await getPackageChanges(packageManager, environment, after); + + assert.strictEqual(changes.length, 2); + assert.deepStrictEqual( + changes.map((c) => c.kind), + [PackageChangeKind.add, PackageChangeKind.add], + ); + assert.deepStrictEqual( + changes.map((c) => c.pkg.name), + ['requests', 'flask'], + ); + }); + + test('returns remove changes for removed packages', async () => { + const before = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'flask', version: '3.0.0' } as Package, + ]; + getPackagesStub.resolves(before); + + const changes = await getPackageChanges(packageManager, environment, []); + + assert.strictEqual(changes.length, 2); + assert.deepStrictEqual( + changes.map((c) => c.kind), + [PackageChangeKind.remove, PackageChangeKind.remove], + ); + }); + + test('detects version upgrade as add and remove', async () => { + const before = [{ name: 'requests', version: '2.30.0' } as Package]; + getPackagesStub.resolves(before); + const after = [{ name: 'requests', version: '2.31.0' } as Package]; + + const changes = await getPackageChanges(packageManager, environment, after); + + assert.strictEqual(changes.length, 2); + const add = changes.find((c) => c.kind === PackageChangeKind.add); + const remove = changes.find((c) => c.kind === PackageChangeKind.remove); + assert.ok(add); + assert.ok(remove); + assert.strictEqual(add.pkg.version, '2.31.0'); + assert.strictEqual(remove.pkg.version, '2.30.0'); + }); + + test('handles mixed additions and removals', async () => { + const before = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'flask', version: '3.0.0' } as Package, + ]; + getPackagesStub.resolves(before); + const after = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'django', version: '5.0.0' } as Package, + ]; + + const changes = await getPackageChanges(packageManager, environment, after); + + assert.strictEqual(changes.length, 2); + const add = changes.find((c) => c.kind === PackageChangeKind.add); + const remove = changes.find((c) => c.kind === PackageChangeKind.remove); + assert.ok(add); + assert.ok(remove); + assert.strictEqual(add.pkg.name, 'django'); + assert.strictEqual(remove.pkg.name, 'flask'); + }); + + test('treats undefined getPackages result as empty', async () => { + getPackagesStub.resolves(undefined); + const after = [{ name: 'requests', version: '2.31.0' } as Package]; + + const changes = await getPackageChanges(packageManager, environment, after); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].kind, PackageChangeKind.add); + }); + }); + + suite('updatePackagesAndNotify', () => { + test('calls setPackages when there are changes', async () => { + getPackagesStub.resolves([]); + const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; + fetchPackagesStub.resolves(fetched); + + await updatePackagesAndNotify(packageManager, environment); + + assert.ok(setPackagesSpy.calledOnce); + const [env, pkgs, changes] = setPackagesSpy.firstCall.args; + assert.strictEqual(env, environment); + assert.deepStrictEqual(pkgs, fetched); + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].kind, PackageChangeKind.add); + }); + + test('does not call setPackages when there are no changes', async () => { + const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; + getPackagesStub.resolves(pkgs); + fetchPackagesStub.resolves(pkgs); + + await updatePackagesAndNotify(packageManager, environment); + + assert.ok(setPackagesSpy.notCalled); + }); + + test('passes all changes to setPackages', async () => { + const before = [{ name: 'flask', version: '3.0.0' } as Package]; + getPackagesStub.resolves(before); + const after = [{ name: 'django', version: '5.0.0' } as Package]; + fetchPackagesStub.resolves(after); + + await updatePackagesAndNotify(packageManager, environment); + + assert.ok(setPackagesSpy.calledOnce); + const [, , changes] = setPackagesSpy.firstCall.args; + assert.strictEqual(changes.length, 2); + assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.add)); + assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.remove)); + }); + }); +}); From fe6390e98d3f7d3f50d0125b8dcc55571a3989cc Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 16:12:02 -0700 Subject: [PATCH 07/15] Fix tests --- src/managers/builtin/pipManager.ts | 4 +++- src/managers/common/packageChanges.ts | 1 + src/managers/conda/condaPackageManager.ts | 4 +++- src/managers/poetry/poetryPackageManager.ts | 4 +++- .../managers/common/packageChanges.unit.test.ts | 17 +++++++++++------ 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index feedbb3c..c4b3c278 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -129,6 +129,8 @@ export class PipPackageManager implements PackageManager, Disposable { changes: { kind: PackageChangeKind; pkg: Package }[], ): void { this.packages.set(environment.envId.id, packages); - this._onDidChangePackages.fire({ environment, manager: this, changes }); + if (changes.length > 0) { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } } } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 0ee5a000..5b47fb01 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -31,6 +31,7 @@ export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, ): Promise { + packageManager.setPackages(environment, [], []); const after = await packageManager.fetchPackages(environment); const changes = await getPackageChanges(packageManager, environment, after); if (changes.length > 0) { diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 83b012f5..b20729cd 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -150,6 +150,8 @@ export class CondaPackageManager implements PackageManager, Disposable { changes: { kind: PackageChangeKind; pkg: Package }[], ): void { this.packages.set(environment.envId.id, packages); - this._onDidChangePackages.fire({ environment, manager: this, changes }); + if (changes.length > 0) { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } } } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 2b3e5ad7..b2ed7fa2 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -254,7 +254,9 @@ export class PoetryPackageManager implements PackageManager, Disposable { changes: { kind: PackageChangeKind; pkg: Package }[], ): void { this.packages.set(environment.envId.id, packages); - this._onDidChangePackages.fire({ environment, manager: this, changes }); + if (changes.length > 0) { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + } } } diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index e200dd75..f1e7c8cf 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -145,22 +145,27 @@ suite('packageChanges', () => { await updatePackagesAndNotify(packageManager, environment); - assert.ok(setPackagesSpy.calledOnce); - const [env, pkgs, changes] = setPackagesSpy.firstCall.args; + assert.strictEqual(setPackagesSpy.callCount, 2); + // First call seeds the cache + assert.deepStrictEqual(setPackagesSpy.firstCall.args, [environment, [], []]); + // Second call sets the actual packages + const [env, pkgs, changes] = setPackagesSpy.secondCall.args; assert.strictEqual(env, environment); assert.deepStrictEqual(pkgs, fetched); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].kind, PackageChangeKind.add); }); - test('does not call setPackages when there are no changes', async () => { + test('does not call setPackages with changes when there are no changes', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(pkgs); fetchPackagesStub.resolves(pkgs); await updatePackagesAndNotify(packageManager, environment); - assert.ok(setPackagesSpy.notCalled); + // Only the seeding call, no second call with changes + assert.strictEqual(setPackagesSpy.callCount, 1); + assert.deepStrictEqual(setPackagesSpy.firstCall.args, [environment, [], []]); }); test('passes all changes to setPackages', async () => { @@ -171,8 +176,8 @@ suite('packageChanges', () => { await updatePackagesAndNotify(packageManager, environment); - assert.ok(setPackagesSpy.calledOnce); - const [, , changes] = setPackagesSpy.firstCall.args; + assert.strictEqual(setPackagesSpy.callCount, 2); + const [, , changes] = setPackagesSpy.secondCall.args; assert.strictEqual(changes.length, 2); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.add)); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.remove)); From 06fb08c678f0df2f1045e13b76a58fb886d0aee2 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 16:40:48 -0700 Subject: [PATCH 08/15] Address PR comments --- src/managers/builtin/pipManager.ts | 4 +- src/managers/common/packageChanges.ts | 30 ++-- src/managers/conda/condaPackageManager.ts | 4 +- src/managers/poetry/poetryPackageManager.ts | 4 +- .../common/packageChanges.unit.test.ts | 145 +++++++++--------- 5 files changed, 98 insertions(+), 89 deletions(-) diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index c4b3c278..19662fb8 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -76,7 +76,7 @@ export class PipPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -101,7 +101,7 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); }, ); } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 5b47fb01..96e20f1a 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -3,12 +3,13 @@ import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; -export async function getPackageChanges( - packageManager: PackageManager, - environment: PythonEnvironment, - after: Package[], -): Promise<{ kind: PackageChangeKind; pkg: Package }[]> { - const before = (await packageManager.getPackages(environment)) ?? []; +/** + * Computes the list of package changes between a before and after snapshot. + * @param before - The previous list of packages. + * @param after - The new list of packages. + * @returns An array of changes indicating which packages were added or removed. + */ +export function getPackageChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { const beforeSet = new Set(before.map(({ name, version }) => `${name}==${version}`)); const afterSet = new Set(after.map(({ name, version }) => `${name}==${version}`)); const changes: { kind: PackageChangeKind; pkg: Package }[] = []; @@ -27,14 +28,21 @@ export async function getPackageChanges( return changes; } +/** + * Fetches the latest packages, computes changes against the current cache, + * and updates the cache. Fires a change event only when there are actual changes. + * + * This function does not call {@link PackageManager.getPackages} to avoid + * re-entering {@link PackageManager.refresh} on a cold cache. Instead, the + * caller should pass the previously cached packages (or an empty array for + * the first load). + */ export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, + before?: Package[], ): Promise { - packageManager.setPackages(environment, [], []); const after = await packageManager.fetchPackages(environment); - const changes = await getPackageChanges(packageManager, environment, after); - if (changes.length > 0) { - packageManager.setPackages(environment, after, changes); - } + const changes = getPackageChanges(before ?? [], after); + packageManager.setPackages(environment, after, changes); } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index b20729cd..f09fab8e 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -73,7 +73,7 @@ export class CondaPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -95,7 +95,7 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); }, ); } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index b2ed7fa2..ddbc46ab 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -83,7 +83,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); } catch (e) { if (e instanceof CancellationError) { throw e; @@ -109,7 +109,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async () => { try { - await updatePackagesAndNotify(this, environment); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); } catch (error) { this.log.error(`Failed to refresh packages: ${error}`); // Show error to user but don't break the UI diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index f1e7c8cf..7c5e7320 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -7,59 +7,32 @@ import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '. import { getPackageChanges, updatePackagesAndNotify } from '../../../managers/common/packageChanges'; suite('packageChanges', () => { - let environment: PythonEnvironment; - let packageManager: PackageManager; - - let getPackagesStub: sinon.SinonStub; - let fetchPackagesStub: sinon.SinonStub; - let setPackagesSpy: sinon.SinonSpy; - - setup(() => { - environment = {} as PythonEnvironment; - getPackagesStub = sinon.stub(); - fetchPackagesStub = sinon.stub(); - setPackagesSpy = sinon.spy(); - - packageManager = { - name: 'test', - manage: sinon.stub(), - refresh: sinon.stub(), - getPackages: getPackagesStub, - fetchPackages: fetchPackagesStub, - setPackages: setPackagesSpy, - } as unknown as PackageManager; - }); - teardown(() => { sinon.restore(); }); suite('getPackageChanges', () => { - test('returns empty array when before and after are identical', async () => { + test('returns empty array when before and after are identical', () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; - getPackagesStub.resolves(pkgs); - const changes = await getPackageChanges(packageManager, environment, pkgs); + const changes = getPackageChanges(pkgs, pkgs); assert.strictEqual(changes.length, 0); }); - test('returns empty array when both before and after are empty', async () => { - getPackagesStub.resolves([]); - - const changes = await getPackageChanges(packageManager, environment, []); + test('returns empty array when both before and after are empty', () => { + const changes = getPackageChanges([], []); assert.strictEqual(changes.length, 0); }); - test('returns add changes for new packages', async () => { - getPackagesStub.resolves([]); + test('returns add changes for new packages', () => { const after = [ { name: 'requests', version: '2.31.0' } as Package, { name: 'flask', version: '3.0.0' } as Package, ]; - const changes = await getPackageChanges(packageManager, environment, after); + const changes = getPackageChanges([], after); assert.strictEqual(changes.length, 2); assert.deepStrictEqual( @@ -72,14 +45,13 @@ suite('packageChanges', () => { ); }); - test('returns remove changes for removed packages', async () => { + test('returns remove changes for removed packages', () => { const before = [ { name: 'requests', version: '2.31.0' } as Package, { name: 'flask', version: '3.0.0' } as Package, ]; - getPackagesStub.resolves(before); - const changes = await getPackageChanges(packageManager, environment, []); + const changes = getPackageChanges(before, []); assert.strictEqual(changes.length, 2); assert.deepStrictEqual( @@ -88,12 +60,11 @@ suite('packageChanges', () => { ); }); - test('detects version upgrade as add and remove', async () => { + test('detects version upgrade as add and remove', () => { const before = [{ name: 'requests', version: '2.30.0' } as Package]; - getPackagesStub.resolves(before); const after = [{ name: 'requests', version: '2.31.0' } as Package]; - const changes = await getPackageChanges(packageManager, environment, after); + const changes = getPackageChanges(before, after); assert.strictEqual(changes.length, 2); const add = changes.find((c) => c.kind === PackageChangeKind.add); @@ -104,18 +75,17 @@ suite('packageChanges', () => { assert.strictEqual(remove.pkg.version, '2.30.0'); }); - test('handles mixed additions and removals', async () => { + test('handles mixed additions and removals', () => { const before = [ { name: 'requests', version: '2.31.0' } as Package, { name: 'flask', version: '3.0.0' } as Package, ]; - getPackagesStub.resolves(before); const after = [ { name: 'requests', version: '2.31.0' } as Package, { name: 'django', version: '5.0.0' } as Package, ]; - const changes = await getPackageChanges(packageManager, environment, after); + const changes = getPackageChanges(before, after); assert.strictEqual(changes.length, 2); const add = changes.find((c) => c.kind === PackageChangeKind.add); @@ -125,59 +95,90 @@ suite('packageChanges', () => { assert.strictEqual(add.pkg.name, 'django'); assert.strictEqual(remove.pkg.name, 'flask'); }); - - test('treats undefined getPackages result as empty', async () => { - getPackagesStub.resolves(undefined); - const after = [{ name: 'requests', version: '2.31.0' } as Package]; - - const changes = await getPackageChanges(packageManager, environment, after); - - assert.strictEqual(changes.length, 1); - assert.strictEqual(changes[0].kind, PackageChangeKind.add); - }); }); suite('updatePackagesAndNotify', () => { - test('calls setPackages when there are changes', async () => { - getPackagesStub.resolves([]); + let environment: PythonEnvironment; + let cache: Package[] | undefined; + let fetchPackagesStub: sinon.SinonStub; + let packageManager: PackageManager; + + setup(() => { + environment = {} as PythonEnvironment; + cache = undefined; + fetchPackagesStub = sinon.stub(); + + packageManager = { + name: 'test', + manage: sinon.stub(), + refresh: sinon.stub(), + getPackages: sinon.stub().callsFake(() => Promise.resolve(cache)), + fetchPackages: fetchPackagesStub, + setPackages: sinon.stub().callsFake((_env: PythonEnvironment, pkgs: Package[]) => { + cache = pkgs; + }), + } as unknown as PackageManager; + }); + + test('updates cache and reports adds on first load', async () => { const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; fetchPackagesStub.resolves(fetched); - await updatePackagesAndNotify(packageManager, environment); + await updatePackagesAndNotify(packageManager, environment, cache); - assert.strictEqual(setPackagesSpy.callCount, 2); - // First call seeds the cache - assert.deepStrictEqual(setPackagesSpy.firstCall.args, [environment, [], []]); - // Second call sets the actual packages - const [env, pkgs, changes] = setPackagesSpy.secondCall.args; + const setPackages = packageManager.setPackages as sinon.SinonStub; + assert.ok(setPackages.calledOnce); + const [env, pkgs, changes] = setPackages.firstCall.args; assert.strictEqual(env, environment); assert.deepStrictEqual(pkgs, fetched); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].kind, PackageChangeKind.add); + assert.deepStrictEqual(cache, fetched); }); - test('does not call setPackages with changes when there are no changes', async () => { + test('updates cache with empty changes when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; - getPackagesStub.resolves(pkgs); + cache = pkgs; fetchPackagesStub.resolves(pkgs); - await updatePackagesAndNotify(packageManager, environment); + await updatePackagesAndNotify(packageManager, environment, cache); - // Only the seeding call, no second call with changes - assert.strictEqual(setPackagesSpy.callCount, 1); - assert.deepStrictEqual(setPackagesSpy.firstCall.args, [environment, [], []]); + const setPackages = packageManager.setPackages as sinon.SinonStub; + assert.ok(setPackages.calledOnce); + const [, , changes] = setPackages.firstCall.args; + assert.strictEqual(changes.length, 0); + }); + + test('detects removals correctly', async () => { + const before = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'flask', version: '3.0.0' } as Package, + ]; + cache = before; + const after = [{ name: 'requests', version: '2.31.0' } as Package]; + fetchPackagesStub.resolves(after); + + await updatePackagesAndNotify(packageManager, environment, cache); + + const setPackages = packageManager.setPackages as sinon.SinonStub; + assert.ok(setPackages.calledOnce); + const [, pkgs, changes] = setPackages.firstCall.args; + assert.deepStrictEqual(pkgs, after); + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].kind, PackageChangeKind.remove); + assert.strictEqual(changes[0].pkg.name, 'flask'); }); - test('passes all changes to setPackages', async () => { - const before = [{ name: 'flask', version: '3.0.0' } as Package]; - getPackagesStub.resolves(before); + test('detects mixed adds and removals', async () => { + cache = [{ name: 'flask', version: '3.0.0' } as Package]; const after = [{ name: 'django', version: '5.0.0' } as Package]; fetchPackagesStub.resolves(after); - await updatePackagesAndNotify(packageManager, environment); + await updatePackagesAndNotify(packageManager, environment, cache); - assert.strictEqual(setPackagesSpy.callCount, 2); - const [, , changes] = setPackagesSpy.secondCall.args; + const setPackages = packageManager.setPackages as sinon.SinonStub; + assert.ok(setPackages.calledOnce); + const [, , changes] = setPackages.firstCall.args; assert.strictEqual(changes.length, 2); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.add)); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.remove)); From 031a9c4b044a04689797e63178e9bb3539e4e829 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 16:42:49 -0700 Subject: [PATCH 09/15] Rename pipPackageManager.ts --- src/managers/builtin/main.ts | 8 ++++---- .../builtin/{pipManager.ts => pipPackageManager.ts} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename src/managers/builtin/{pipManager.ts => pipPackageManager.ts} (100%) diff --git a/src/managers/builtin/main.ts b/src/managers/builtin/main.ts index fdda603b..4fff2dae 100644 --- a/src/managers/builtin/main.ts +++ b/src/managers/builtin/main.ts @@ -4,7 +4,7 @@ import { createSimpleDebounce } from '../../common/utils/debounce'; import { createFileSystemWatcher, onDidDeleteFiles } from '../../common/workspace.apis'; import { getPythonApi } from '../../features/pythonApi'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { PipPackageManager } from './pipManager'; +import { PipPackageManager } from './pipPackageManager'; import { SysPythonManager } from './sysPythonManager'; import { VenvManager } from './venvManager'; @@ -60,10 +60,10 @@ export async function registerSystemPythonFeatures( ); }); const packageWatcher = createFileSystemWatcher( - '**/site-packages/*.dist-info/METADATA', + '**/site-packages/*.dist-info/METADATA', false, // don't ignore create events (pip install) - true, // ignore change events (content changes in METADATA don't affect package list) - false // don't ignore delete events (pip uninstall) + true, // ignore change events (content changes in METADATA don't affect package list) + false, // don't ignore delete events (pip uninstall) ); disposables.push( packageDebouncedRefresh, diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipPackageManager.ts similarity index 100% rename from src/managers/builtin/pipManager.ts rename to src/managers/builtin/pipPackageManager.ts From 706ba84efea99dae2aab471e5bdd8044f06f4258 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 21 May 2026 16:44:02 -0700 Subject: [PATCH 10/15] Rename execPipList --- src/managers/builtin/utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 6877f72d..354a1390 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -185,12 +185,12 @@ export async function refreshPythons( const PIP_LIST_TIMEOUT_MS = 30_000; -async function refreshPipPackagesRaw(environment: PythonEnvironment, log?: LogOutputChannel): Promise { +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'], + ['pip', 'list', '--python', environment.execInfo.run.executable, '--format=json', ...(args ?? [])], undefined, log, undefined, @@ -228,11 +228,11 @@ export async function refreshPipPackages( location: ProgressLocation.Notification, }, async () => { - return await refreshPipPackagesRaw(environment, log); + return await execPipList(environment, log); }, ); } else { - data = await refreshPipPackagesRaw(environment, log); + data = await execPipList(environment, log); } return parsePipListJson(data); From 33bb98bae9decab26790439953abe550789cae42 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 27 May 2026 17:22:42 -0700 Subject: [PATCH 11/15] Condense fetchPackages and getPakages methods into getPackages --- api/src/main.ts | 25 ++++--- examples/sample1/src/api.ts | 25 ++++--- src/api.ts | 25 ++++--- src/features/pythonApi.ts | 11 +-- src/internal.api.ts | 9 +-- src/managers/builtin/pipPackageManager.ts | 15 ++-- src/managers/common/packageChanges.ts | 2 +- src/managers/conda/condaPackageManager.ts | 68 +++++++++---------- src/managers/poetry/poetryPackageManager.ts | 11 +-- .../common/packageChanges.unit.test.ts | 15 ++-- 10 files changed, 109 insertions(+), 97 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index 7635f4f4..8786215f 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -677,17 +677,10 @@ export interface PackageManager { /** * Retrieves the list of packages for the specified Python environment. * @param environment - The Python environment for which to retrieve packages. + * @param options - Optional settings for package retrieval. * @returns An array of packages, or undefined if the packages could not be retrieved. */ - getPackages(environment: PythonEnvironment): Promise; - - /** - * Fetches the latest list of packages from the package manager for the specified Python environment. - * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. - * @param environment - The Python environment for which to fetch packages. - * @returns A promise that resolves to an array of packages. - */ - fetchPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Updates the cached packages for the specified environment and fires a change event. @@ -814,6 +807,17 @@ export interface DidChangePythonProjectsEventArgs { removed: PythonProject[]; } +/** + * Options for retrieving packages from a package manager. + */ +export interface GetPackagesOptions { + /** + * When `true`, bypasses the cache and fetches the latest packages from the underlying tool. + * Defaults to `false`. + */ + skipCache?: boolean; +} + export type PackageManagementOptions = | { /** @@ -1045,9 +1049,10 @@ export interface PythonPackageGetterApi { * Get the list of packages in a Python Environment. * * @param environment The Python Environment for which the list of packages is required. + * @param options Optional settings for package retrieval. * @returns The list of packages in the Python Environment. */ - getPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Event raised when the list of packages in a Python Environment changes. diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index eb87337c..1391ff77 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -611,17 +611,10 @@ export interface PackageManager { /** * Retrieves the list of packages for the specified Python environment. * @param environment - The Python environment for which to retrieve packages. + * @param options - Optional settings for package retrieval. * @returns An array of packages, or undefined if the packages could not be retrieved. */ - getPackages(environment: PythonEnvironment): Promise; - - /** - * Fetches the latest list of packages from the package manager for the specified Python environment. - * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. - * @param environment - The Python environment for which to fetch packages. - * @returns A promise that resolves to an array of packages. - */ - fetchPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Updates the cached packages for the specified environment and fires a change event. @@ -734,6 +727,17 @@ export interface DidChangePythonProjectsEventArgs { removed: PythonProject[]; } +/** + * Options for retrieving packages from a package manager. + */ +export interface GetPackagesOptions { + /** + * When `true`, bypasses the cache and fetches the latest packages from the underlying tool. + * Defaults to `false`. + */ + skipCache?: boolean; +} + /** * Options for package management. */ @@ -970,9 +974,10 @@ export interface PythonPackageGetterApi { * Get the list of packages in a Python Environment. * * @param environment The Python Environment for which the list of packages is required. + * @param options Optional settings for package retrieval. * @returns The list of packages in the Python Environment. */ - getPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Event raised when the list of packages in a Python Environment changes. diff --git a/src/api.ts b/src/api.ts index c2c07545..0bacf29c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -671,17 +671,10 @@ export interface PackageManager { /** * Retrieves the list of packages for the specified Python environment. * @param environment - The Python environment for which to retrieve packages. + * @param options - Optional settings for package retrieval. * @returns An array of packages, or undefined if the packages could not be retrieved. */ - getPackages(environment: PythonEnvironment): Promise; - - /** - * Fetches the latest list of packages from the package manager for the specified Python environment. - * Unlike {@link getPackages}, this always queries the underlying tool and does not use cached results. - * @param environment - The Python environment for which to fetch packages. - * @returns A promise that resolves to an array of packages. - */ - fetchPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Updates the cached packages for the specified environment and fires a change event. @@ -808,6 +801,17 @@ export interface DidChangePythonProjectsEventArgs { removed: PythonProject[]; } +/** + * Options for retrieving packages from a package manager. + */ +export interface GetPackagesOptions { + /** + * When `true`, bypasses the cache and fetches the latest packages from the underlying tool. + * Defaults to `false`. + */ + skipCache?: boolean; +} + export type PackageManagementOptions = | { /** @@ -1039,9 +1043,10 @@ export interface PythonPackageGetterApi { * Get the list of packages in a Python Environment. * * @param environment The Python Environment for which the list of packages is required. + * @param options Optional settings for package retrieval. * @returns The list of packages in the Python Environment. */ - getPackages(environment: PythonEnvironment): Promise; + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; /** * Event raised when the list of packages in a Python Environment changes. diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index f071b0f5..b20de34f 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -10,6 +10,7 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackagesOptions, Package, PackageId, PackageInfo, @@ -96,9 +97,9 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { // *selected* manager's changes propagate (refreshEnvironment checks // getEnvironmentManager(scope) internally). It updates the cache and // fires onDidChangeActiveEnvironment, which the Python API listens to. - this.envManagers.refreshEnvironment(e.uri).catch((err) => - traceError('Failed to refresh environment on change:', err), - ); + this.envManagers + .refreshEnvironment(e.uri) + .catch((err) => traceError('Failed to refresh environment on change:', err)); }); }), ); @@ -257,13 +258,13 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } return manager.refresh(context); } - async getPackages(context: PythonEnvironment): Promise { + async getPackages(context: PythonEnvironment, options?: GetPackagesOptions): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { return Promise.resolve(undefined); } - return manager.getPackages(context); + return manager.getPackages(context, options); } onDidChangePackages: Event = this._onDidChangePackages.event; createPackageItem(info: PackageInfo, environment: PythonEnvironment, manager: PackageManager): Package { diff --git a/src/internal.api.ts b/src/internal.api.ts index 748dbb03..ed5ce374 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -9,6 +9,7 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackagesOptions, IconPath, Package, PackageChangeKind, @@ -367,12 +368,8 @@ export class InternalPackageManager implements PackageManager { return this.manager.refresh(environment); } - getPackages(environment: PythonEnvironment): Promise { - return this.manager.getPackages(environment); - } - - fetchPackages(environment: PythonEnvironment): Promise { - return this.manager.fetchPackages(environment); + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { + return this.manager.getPackages(environment, options); } setPackages( diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 19662fb8..9210e095 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -11,6 +11,7 @@ import { } from 'vscode'; import { DidChangePackagesEventArgs, + GetPackagesOptions, IconPath, Package, PackageChangeKind, @@ -106,9 +107,12 @@ export class PipPackageManager implements PackageManager, Disposable { ); } - async getPackages(environment: PythonEnvironment): Promise { - if (!this.packages.has(environment.envId.id)) { - await this.refresh(environment); + async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { + if (options?.skipCache || !this.packages.has(environment.envId.id)) { + const data = await refreshPipPackages(environment, this.log); + const packages = (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); + this.packages.set(environment.envId.id, packages); + return packages; } return this.packages.get(environment.envId.id); } @@ -118,11 +122,6 @@ export class PipPackageManager implements PackageManager, Disposable { this.packages.clear(); } - async fetchPackages(environment: PythonEnvironment): Promise { - const data = await refreshPipPackages(environment, this.log); - return (data ?? []).map((pkg) => this.api.createPackageItem(pkg, environment, this)); - } - setPackages( environment: PythonEnvironment, packages: Package[], diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 96e20f1a..e7a5b14f 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -42,7 +42,7 @@ export async function updatePackagesAndNotify( environment: PythonEnvironment, before?: Package[], ): Promise { - const after = await packageManager.fetchPackages(environment); + const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; const changes = getPackageChanges(before ?? [], after); packageManager.setPackages(environment, after, changes); } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index f09fab8e..a2c7dfc2 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -9,6 +9,7 @@ import { } from 'vscode'; import { DidChangePackagesEventArgs, + GetPackagesOptions, IconPath, Package, PackageChangeKind, @@ -100,9 +101,38 @@ export class CondaPackageManager implements PackageManager, Disposable { ); } - async getPackages(environment: PythonEnvironment): Promise { - if (!this.packages.has(environment.envId.id)) { - await this.refresh(environment); + 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, + ), + ); + } + } + this.packages.set(environment.envId.id, packages); + return packages; } return this.packages.get(environment.envId.id); } @@ -112,38 +142,6 @@ export class CondaPackageManager implements PackageManager, Disposable { this.packages.clear(); } - async fetchPackages(environment: PythonEnvironment): Promise { - 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, - ), - ); - } - } - return packages; - } - setPackages( environment: PythonEnvironment, packages: Package[], diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index ddbc46ab..a510fa92 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -14,6 +14,7 @@ import { import { Disposable } from 'vscode-jsonrpc'; import { DidChangePackagesEventArgs, + GetPackagesOptions, IconPath, Package, PackageChangeKind, @@ -124,9 +125,11 @@ export class PoetryPackageManager implements PackageManager, Disposable { ); } - async getPackages(environment: PythonEnvironment): Promise { - if (!this.packages.has(environment.envId.id)) { - await this.refresh(environment); + async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { + if (options?.skipCache || !this.packages.has(environment.envId.id)) { + const packages = await this.fetchPackagesFromTool(environment); + this.packages.set(environment.envId.id, packages); + return packages; } return this.packages.get(environment.envId.id); } @@ -176,7 +179,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - async fetchPackages(environment: PythonEnvironment): Promise { + private async fetchPackagesFromTool(environment: PythonEnvironment): Promise { const poetry = await getPoetry(); if (!poetry) { throw new Error( diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 7c5e7320..eeeb1819 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -100,20 +100,19 @@ suite('packageChanges', () => { suite('updatePackagesAndNotify', () => { let environment: PythonEnvironment; let cache: Package[] | undefined; - let fetchPackagesStub: sinon.SinonStub; + let getPackagesStub: sinon.SinonStub; let packageManager: PackageManager; setup(() => { environment = {} as PythonEnvironment; cache = undefined; - fetchPackagesStub = sinon.stub(); + getPackagesStub = sinon.stub(); packageManager = { name: 'test', manage: sinon.stub(), refresh: sinon.stub(), - getPackages: sinon.stub().callsFake(() => Promise.resolve(cache)), - fetchPackages: fetchPackagesStub, + getPackages: getPackagesStub, setPackages: sinon.stub().callsFake((_env: PythonEnvironment, pkgs: Package[]) => { cache = pkgs; }), @@ -122,7 +121,7 @@ suite('packageChanges', () => { test('updates cache and reports adds on first load', async () => { const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; - fetchPackagesStub.resolves(fetched); + getPackagesStub.resolves(fetched); await updatePackagesAndNotify(packageManager, environment, cache); @@ -139,7 +138,7 @@ suite('packageChanges', () => { test('updates cache with empty changes when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; cache = pkgs; - fetchPackagesStub.resolves(pkgs); + getPackagesStub.resolves(pkgs); await updatePackagesAndNotify(packageManager, environment, cache); @@ -156,7 +155,7 @@ suite('packageChanges', () => { ]; cache = before; const after = [{ name: 'requests', version: '2.31.0' } as Package]; - fetchPackagesStub.resolves(after); + getPackagesStub.resolves(after); await updatePackagesAndNotify(packageManager, environment, cache); @@ -172,7 +171,7 @@ suite('packageChanges', () => { test('detects mixed adds and removals', async () => { cache = [{ name: 'flask', version: '3.0.0' } as Package]; const after = [{ name: 'django', version: '5.0.0' } as Package]; - fetchPackagesStub.resolves(after); + getPackagesStub.resolves(after); await updatePackagesAndNotify(packageManager, environment, cache); From 6dbe30aa511edcb67116de85bc181a9d298ff5a8 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 27 May 2026 17:43:30 -0700 Subject: [PATCH 12/15] Remove setPackages --- api/src/main.ts | 12 ----- examples/sample1/src/api.ts | 12 ----- src/api.ts | 12 ----- src/internal.api.ts | 8 --- src/managers/builtin/pipPackageManager.ts | 30 +++++------ src/managers/common/packageChanges.ts | 12 ++++- src/managers/conda/condaPackageManager.ts | 30 +++++------ src/managers/poetry/poetryPackageManager.ts | 30 +++++------ .../common/packageChanges.unit.test.ts | 50 +++++++------------ 9 files changed, 76 insertions(+), 120 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index 8786215f..aa381414 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -682,18 +682,6 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; - /** - * Updates the cached packages for the specified environment and fires a change event. - * @param environment - The Python environment whose packages changed. - * @param packages - The new list of packages. - * @param changes - The list of changes describing what was added or removed. - */ - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void; - /** * Event that is fired when packages change. */ diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index 1391ff77..b44e79b2 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -616,18 +616,6 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; - /** - * Updates the cached packages for the specified environment and fires a change event. - * @param environment - The Python environment whose packages changed. - * @param packages - The new list of packages. - * @param changes - The list of changes describing what was added or removed. - */ - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void; - /** * Event that is fired when packages change. */ diff --git a/src/api.ts b/src/api.ts index 0bacf29c..b3ab24cb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -676,18 +676,6 @@ export interface PackageManager { */ getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise; - /** - * Updates the cached packages for the specified environment and fires a change event. - * @param environment - The Python environment whose packages changed. - * @param packages - The new list of packages. - * @param changes - The list of changes describing what was added or removed. - */ - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void; - /** * Event that is fired when packages change. */ diff --git a/src/internal.api.ts b/src/internal.api.ts index ed5ce374..4c80b527 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -372,14 +372,6 @@ export class InternalPackageManager implements PackageManager { return this.manager.getPackages(environment, options); } - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void { - this.manager.setPackages(environment, packages, changes); - } - onDidChangePackages(handler: (e: DidChangePackagesEventArgs) => void): Disposable { return this.manager.onDidChangePackages ? this.manager.onDidChangePackages(handler) : new Disposable(() => {}); } diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 9210e095..c59c262b 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -14,7 +14,6 @@ import { GetPackagesOptions, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -77,7 +76,14 @@ export class PipPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, this, token); - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + 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; @@ -102,7 +108,14 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); }, ); } @@ -121,15 +134,4 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.dispose(); this.packages.clear(); } - - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void { - this.packages.set(environment.envId.id, packages); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } - } } diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index e7a5b14f..c1fa1cb6 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -3,6 +3,11 @@ import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; +/** + * Callback invoked with the computed changes when at least one change is detected. + */ +export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: Package }[]) => void; + /** * Computes the list of package changes between a before and after snapshot. * @param before - The previous list of packages. @@ -40,9 +45,12 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, - before?: Package[], + before: Package[] | undefined, + onChanges: PackageChangesCallback, ): Promise { const after = (await packageManager.getPackages(environment, { skipCache: true })) ?? []; const changes = getPackageChanges(before ?? [], after); - packageManager.setPackages(environment, after, changes); + if (changes.length > 0) { + onChanges(changes); + } } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index a2c7dfc2..55dbc8a1 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -12,7 +12,6 @@ import { GetPackagesOptions, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -74,7 +73,14 @@ export class CondaPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await managePackages(environment, manageOptions, token, this.log); - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + 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; @@ -96,7 +102,14 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); }, ); } @@ -141,15 +154,4 @@ export class CondaPackageManager implements PackageManager, Disposable { this._onDidChangePackages.dispose(); this.packages.clear(); } - - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void { - this.packages.set(environment.envId.id, packages); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } - } } diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index a510fa92..0498e225 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -17,7 +17,6 @@ import { GetPackagesOptions, IconPath, Package, - PackageChangeKind, PackageManagementOptions, PackageManager, PythonEnvironment, @@ -84,7 +83,14 @@ export class PoetryPackageManager implements PackageManager, Disposable { async (_progress, token) => { try { await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, token); - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + 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; @@ -110,7 +116,14 @@ export class PoetryPackageManager implements PackageManager, Disposable { }, async () => { try { - await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id)); + await updatePackagesAndNotify( + this, + environment, + this.packages.get(environment.envId.id), + (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }, + ); } catch (error) { this.log.error(`Failed to refresh packages: ${error}`); // Show error to user but don't break the UI @@ -250,17 +263,6 @@ export class PoetryPackageManager implements PackageManager, Disposable { // Convert to Package objects using the API return poetryPackages.map((pkg) => this.api.createPackageItem(pkg, environment, this)); } - - setPackages( - environment: PythonEnvironment, - packages: Package[], - changes: { kind: PackageChangeKind; pkg: Package }[], - ): void { - this.packages.set(environment.envId.id, packages); - if (changes.length > 0) { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - } - } } export async function runPoetry( diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index eeeb1819..1fe25c0a 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -99,53 +99,41 @@ suite('packageChanges', () => { suite('updatePackagesAndNotify', () => { let environment: PythonEnvironment; - let cache: Package[] | undefined; let getPackagesStub: sinon.SinonStub; let packageManager: PackageManager; setup(() => { environment = {} as PythonEnvironment; - cache = undefined; getPackagesStub = sinon.stub(); - packageManager = { name: 'test', manage: sinon.stub(), refresh: sinon.stub(), getPackages: getPackagesStub, - setPackages: sinon.stub().callsFake((_env: PythonEnvironment, pkgs: Package[]) => { - cache = pkgs; - }), } as unknown as PackageManager; }); - test('updates cache and reports adds on first load', async () => { + test('reports adds on first load', async () => { const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(fetched); + const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, cache); + await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); - const setPackages = packageManager.setPackages as sinon.SinonStub; - assert.ok(setPackages.calledOnce); - const [env, pkgs, changes] = setPackages.firstCall.args; - assert.strictEqual(env, environment); - assert.deepStrictEqual(pkgs, fetched); + assert.ok(onChanges.calledOnce); + const [changes] = onChanges.firstCall.args; assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].kind, PackageChangeKind.add); - assert.deepStrictEqual(cache, fetched); }); - test('updates cache with empty changes when nothing changed', async () => { + test('does not fire callback when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; - cache = pkgs; getPackagesStub.resolves(pkgs); + const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, cache); + await updatePackagesAndNotify(packageManager, environment, pkgs, onChanges); - const setPackages = packageManager.setPackages as sinon.SinonStub; - assert.ok(setPackages.calledOnce); - const [, , changes] = setPackages.firstCall.args; - assert.strictEqual(changes.length, 0); + assert.ok(onChanges.notCalled); }); test('detects removals correctly', async () => { @@ -153,31 +141,29 @@ suite('packageChanges', () => { { name: 'requests', version: '2.31.0' } as Package, { name: 'flask', version: '3.0.0' } as Package, ]; - cache = before; const after = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(after); + const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, cache); + await updatePackagesAndNotify(packageManager, environment, before, onChanges); - const setPackages = packageManager.setPackages as sinon.SinonStub; - assert.ok(setPackages.calledOnce); - const [, pkgs, changes] = setPackages.firstCall.args; - assert.deepStrictEqual(pkgs, after); + assert.ok(onChanges.calledOnce); + const [changes] = onChanges.firstCall.args; assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].kind, PackageChangeKind.remove); assert.strictEqual(changes[0].pkg.name, 'flask'); }); test('detects mixed adds and removals', async () => { - cache = [{ name: 'flask', version: '3.0.0' } as Package]; + const before = [{ name: 'flask', version: '3.0.0' } as Package]; const after = [{ name: 'django', version: '5.0.0' } as Package]; getPackagesStub.resolves(after); + const onChanges = sinon.stub(); - await updatePackagesAndNotify(packageManager, environment, cache); + await updatePackagesAndNotify(packageManager, environment, before, onChanges); - const setPackages = packageManager.setPackages as sinon.SinonStub; - assert.ok(setPackages.calledOnce); - const [, , changes] = setPackages.firstCall.args; + assert.ok(onChanges.calledOnce); + const [changes] = onChanges.firstCall.args; assert.strictEqual(changes.length, 2); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.add)); assert.ok(changes.some((c: { kind: PackageChangeKind }) => c.kind === PackageChangeKind.remove)); From 1fb35b6b9a28577aaafce18dacd6e6c0aa6b9098 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 28 May 2026 09:26:57 -0700 Subject: [PATCH 13/15] Formatting --- src/managers/builtin/pipPackageManager.ts | 11 +++-------- src/managers/conda/condaPackageManager.ts | 11 +++-------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index c59c262b..1a517adc 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -108,14 +108,9 @@ export class PipPackageManager implements PackageManager, Disposable { title: 'Refreshing packages', }, async () => { - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); }, ); } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index 55dbc8a1..6a492ffa 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -102,14 +102,9 @@ export class CondaPackageManager implements PackageManager, Disposable { title: CondaStrings.condaRefreshingPackages, }, async () => { - await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this._onDidChangePackages.fire({ environment, manager: this, changes }); - }, - ); + await updatePackagesAndNotify(this, environment, this.packages.get(environment.envId.id), (changes) => { + this._onDidChangePackages.fire({ environment, manager: this, changes }); + }); }, ); } From 03cd8f091e8167743c77d5bf1f129c6427d2555c Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 28 May 2026 09:35:56 -0700 Subject: [PATCH 14/15] Fix tests --- src/test/features/packageManager.api.unit.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/test/features/packageManager.api.unit.test.ts b/src/test/features/packageManager.api.unit.test.ts index 9f4b98b1..c4f2416a 100644 --- a/src/test/features/packageManager.api.unit.test.ts +++ b/src/test/features/packageManager.api.unit.test.ts @@ -343,7 +343,7 @@ suite('PythonPackageManagerApi Tests', () => { }, ]; packageManager - .setup((pm) => pm.getPackages(environment.object)) + .setup((pm) => pm.getPackages(environment.object, typeMoq.It.isAny())) .returns(() => Promise.resolve(mockPackages)) .verifiable(typeMoq.Times.once()); @@ -360,7 +360,7 @@ suite('PythonPackageManagerApi Tests', () => { test('Should return undefined when no packages found', async () => { // Mock - Package manager returns undefined packageManager - .setup((pm) => pm.getPackages(environment.object)) + .setup((pm) => pm.getPackages(environment.object, typeMoq.It.isAny())) .returns(() => Promise.resolve(undefined)) .verifiable(typeMoq.Times.once()); @@ -375,7 +375,7 @@ suite('PythonPackageManagerApi Tests', () => { test('Should return empty array when environment has no packages', async () => { // Mock - Package manager returns empty array packageManager - .setup((pm) => pm.getPackages(environment.object)) + .setup((pm) => pm.getPackages(environment.object, typeMoq.It.isAny())) .returns(() => Promise.resolve([])) .verifiable(typeMoq.Times.once()); @@ -402,7 +402,7 @@ suite('PythonPackageManagerApi Tests', () => { }, ]; packageManager - .setup((pm) => pm.getPackages(environment.object)) + .setup((pm) => pm.getPackages(environment.object, typeMoq.It.isAny())) .returns(() => Promise.resolve(mockPackages)); // Run @@ -422,7 +422,9 @@ suite('PythonPackageManagerApi Tests', () => { test('Should propagate errors from package manager getPackages method', async () => { // Mock - Package manager throws error const testError = new Error('Failed to get packages'); - packageManager.setup((pm) => pm.getPackages(environment.object)).returns(() => Promise.reject(testError)); + packageManager + .setup((pm) => pm.getPackages(environment.object, typeMoq.It.isAny())) + .returns(() => Promise.reject(testError)); // Run & Assert - Should reject with same error await assert.rejects( From 27afdf445b049595b906c5088ea3e9a9ae4d9224 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 29 May 2026 14:35:51 -0700 Subject: [PATCH 15/15] Address PR comments --- src/managers/common/packageChanges.ts | 7 +++---- src/managers/conda/condaUtils.ts | 3 --- src/test/managers/common/packageChanges.unit.test.ts | 1 + 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index c1fa1cb6..c2afa122 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -37,10 +37,9 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: * Fetches the latest packages, computes changes against the current cache, * and updates the cache. Fires a change event only when there are actual changes. * - * This function does not call {@link PackageManager.getPackages} to avoid - * re-entering {@link PackageManager.refresh} on a cold cache. Instead, the - * caller should pass the previously cached packages (or an empty array for - * the first load). + * This function calls {@link PackageManager.getPackages} with `skipCache` to fetch + * the latest snapshot. The caller should pass the previously cached packages + * so changes can be computed against the pre-refresh state. */ export async function updatePackagesAndNotify( packageManager: PackageManager, diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 6d4a207b..590284c5 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1274,9 +1274,6 @@ export async function deleteCondaEnvironment(environment: PythonEnvironment, log ); } -/** - * JSON structure returned by `conda list --json` - */ export async function managePackages( environment: PythonEnvironment, options: PackageManagementOptions, diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 1fe25c0a..81e8235f 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -120,6 +120,7 @@ suite('packageChanges', () => { await updatePackagesAndNotify(packageManager, environment, undefined, onChanges); + assert.ok(getPackagesStub.calledOnceWithExactly(environment, sinon.match({ skipCache: true }))); assert.ok(onChanges.calledOnce); const [changes] = onChanges.firstCall.args; assert.strictEqual(changes.length, 1);