From 869d940367efdea43b90b331cac4e91c02337767 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:31:45 +0000 Subject: [PATCH 1/2] Drop entry-point distribution info when unregistering a plugin Key the recorded distributions by plugin name instead of keeping a list of (plugin, dist) tuples. unregister() pops the name, so the listing no longer shows unloaded plugins, and reloading an entry point overwrites its slot instead of appending a duplicate. Fixes #763. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018ZTVJoihfHRVor2TQqGTRn --- changelog/763.bugfix.rst | 1 + src/pluggy/_manager.py | 16 ++++++++++------ testing/test_pluginmanager.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 changelog/763.bugfix.rst diff --git a/changelog/763.bugfix.rst b/changelog/763.bugfix.rst new file mode 100644 index 00000000..ddb6a7d0 --- /dev/null +++ b/changelog/763.bugfix.rst @@ -0,0 +1 @@ +:meth:`PluginManager.unregister() ` now also drops the plugin's entry from :meth:`~pluggy.PluginManager.list_plugin_distinfo` and :meth:`~pluggy.PluginManager.list_plugin_distributions`, so unloaded plugins no longer show up there and reloading them no longer adds duplicates. diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 4994c867..c505e5aa 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -159,9 +159,7 @@ def __init__(self, project_name: str) -> None: #: The project name. self.project_name: Final = project_name self._name2plugin: Final[dict[str, _Plugin]] = {} - self._plugin_distinfo: Final[ - list[tuple[_Plugin, importlib.metadata.Distribution]] - ] = [] + self._name2dist: Final[dict[str, importlib.metadata.Distribution]] = {} #: The "hook relay", used to call a hook on all registered plugins. #: See :ref:`calling`. self.hook: Final = HookRelay() @@ -316,6 +314,7 @@ def unregister( # if self._name2plugin[name] == None registration was blocked: ignore if self._name2plugin.get(name) is not None: del self._name2plugin[name] + self._name2dist.pop(name, None) return plugin @@ -517,7 +516,7 @@ def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> in continue plugin = ep.load() self.register(plugin, name=ep.name) - self._plugin_distinfo.append((plugin, dist)) + self._name2dist[ep.name] = dist count += 1 return count @@ -533,7 +532,10 @@ def list_plugin_distinfo(self) -> list[tuple[_Plugin, DistFacade]]: """ from ._compat import DistFacade - return [(plugin, DistFacade(dist)) for plugin, dist in self._plugin_distinfo] + return [ + (plugin, DistFacade(dist)) + for plugin, dist in self.list_plugin_distributions() + ] def list_plugin_distributions( self, @@ -543,7 +545,9 @@ def list_plugin_distributions( .. versionadded:: 1.7 """ - return list(self._plugin_distinfo) + return [ + (self._name2plugin[name], dist) for name, dist in self._name2dist.items() + ] def list_name_plugin(self) -> list[tuple[str, _Plugin]]: """Return a list of (name, plugin) pairs for all registered plugins.""" diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 4be3dc9a..ac2aa463 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -939,6 +939,39 @@ def my_distributions(): assert ret_distributions == [(plugin, dist)] +def test_unregister_drops_plugin_distribution( + monkeypatch: pytest.MonkeyPatch, pm: PluginManager +) -> None: + class EntryPoint: + group = "hello" + + def __init__(self, name: str) -> None: + self.name = name + + def load(self) -> object: + return type(self.name, (), {})() + + class Distribution: + entry_points = (EntryPoint("a"), EntryPoint("b")) + + dist = cast(importlib.metadata.Distribution, Distribution()) + monkeypatch.setattr(importlib.metadata, "distributions", lambda: (dist,)) + + assert pm.load_setuptools_entrypoints("hello") == 2 + b = pm.get_plugin("b") + pm.unregister(name="a") + assert pm.list_plugin_distributions() == [(b, dist)] + assert [p for p, _ in pm.list_plugin_distinfo()] == [b] + + # reloading must not duplicate the remaining entry + assert pm.load_setuptools_entrypoints("hello") == 1 + a = pm.get_plugin("a") + assert pm.list_plugin_distributions() == [(b, dist), (a, dist)] + + pm.set_blocked("b") + assert pm.list_plugin_distributions() == [(a, dist)] + + def test_add_tracefuncs(he_pm: PluginManager) -> None: out: list[Any] = [] From c72b18450a2eab5e057c0aa26c9e8523fdfd8318 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:48:41 +0000 Subject: [PATCH 2/2] Simplify unregister cleanup to dict pops The truthiness guard only had to keep the block marker of a blocked name in place, and the assert only existed to narrow the type for mypy (added with the 2019 type annotations). Say that directly with is_blocked() and pop, and make test_unregister_blocked check that the block survives, which it did not cover before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018ZTVJoihfHRVor2TQqGTRn --- src/pluggy/_manager.py | 5 ++--- testing/test_pluginmanager.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index c505e5aa..974f4b02 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -311,9 +311,8 @@ def unregister( for hookcaller in hookcallers: hookcaller._remove_plugin(plugin) - # if self._name2plugin[name] == None registration was blocked: ignore - if self._name2plugin.get(name) is not None: - del self._name2plugin[name] + if not self.is_blocked(name): + self._name2plugin.pop(name, None) self._name2dist.pop(name, None) return plugin diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index ac2aa463..f643f14d 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -488,6 +488,7 @@ class Plugin: # bloked plugins can be unregistred many times atm pm.unregister(p, "error") pm.unregister(p, "error") + assert pm.is_blocked("error") def test_register_unknown_hooks(pm: PluginManager) -> None: