From acbef6cc63b40c3e0871ea0c26a6884e77f6636f Mon Sep 17 00:00:00 2001 From: ciperx Date: Fri, 16 Oct 2026 15:44:36 +0000 Subject: [PATCH 1/2] fix(manager): drop the distinfo entry when a plugin is unregistered PluginManager.unregister removed the plugin from its hook callers and from _name2plugin, but left its _plugin_distinfo entry behind. A plugin loaded through entry points therefore kept being reported by list_plugin_distinfo() and list_plugin_distributions() after unregister(), and re-registering it appended a second entry for the same plugin. unregister now removes every distinfo entry belonging to the plugin, so both listings reflect the live registry. Adds regression coverage for the name form, the plugin-object form, and the unregister-then-reload path. --- src/pluggy/_manager.py | 8 +++ testing/test_unregister_distinfo.py | 93 +++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 testing/test_unregister_distinfo.py diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index aa54088c..22aac4f2 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -320,6 +320,14 @@ def unregister( if self._name2plugin.get(name) is not None: del self._name2plugin[name] + # Drop the entry-point bookkeeping for this plugin as well. Leaving it in + # place made list_plugin_distinfo()/list_plugin_distributions() keep + # reporting an unregistered plugin, and a later re-registration appended a + # second entry for the same plugin. + self._plugin_distinfo[:] = [ + (p, dist) for p, dist in self._plugin_distinfo if p is not plugin + ] + return plugin def set_blocked(self, name: str) -> None: diff --git a/testing/test_unregister_distinfo.py b/testing/test_unregister_distinfo.py new file mode 100644 index 00000000..bf606755 --- /dev/null +++ b/testing/test_unregister_distinfo.py @@ -0,0 +1,93 @@ +"""Regression coverage for pluggy issue #763. + +``PluginManager.unregister`` removed the plugin from the hook callers and from +``_name2plugin`` but left its ``_plugin_distinfo`` entry behind. A plugin loaded +through entry points therefore kept appearing in ``list_plugin_distinfo()`` and +``list_plugin_distributions()`` after being unregistered, and re-registering it +appended a second entry for the same plugin. +""" + +from __future__ import annotations + +import importlib.metadata +from typing import cast + +import pytest + +from pluggy import PluginManager + + +def _install_entrypoint_dist(monkeypatch: pytest.MonkeyPatch, holder: dict) -> None: + """Make importlib.metadata report one distribution with a single entry point.""" + + class EntryPoint: + name = "myname" + group = "hello" + value = "myname:foo" + + def load(self): + holder["loads"] = holder.get("loads", 0) + 1 + return holder["plugin"] + + class Distribution: + entry_points = (EntryPoint(),) + + dist = cast(importlib.metadata.Distribution, Distribution()) + monkeypatch.setattr(importlib.metadata, "distributions", lambda: (dist,)) + holder["dist"] = dist + + +def test_unregister_drops_the_plugin_distinfo_entry( + monkeypatch: pytest.MonkeyPatch, pm: PluginManager +) -> None: + """An unregistered entry-point plugin must not be listed any more.""" + holder: dict = {"plugin": type("PseudoPlugin", (), {})()} + _install_entrypoint_dist(monkeypatch, holder) + + assert pm.load_setuptools_entrypoints("hello") == 1 + plugin = pm.get_plugin("myname") + assert plugin is holder["plugin"] + assert len(pm.list_plugin_distributions()) == 1 + assert len(pm.list_plugin_distinfo()) == 1 + + assert pm.unregister(name="myname") is plugin + + assert pm.get_plugin("myname") is None + assert pm.list_plugin_distributions() == [] + assert pm.list_plugin_distinfo() == [] + + +def test_unregister_then_reregister_does_not_duplicate_distinfo( + monkeypatch: pytest.MonkeyPatch, pm: PluginManager +) -> None: + """Re-registering must not leave two entries for the same plugin.""" + holder: dict = {"plugin": type("PseudoPlugin", (), {})()} + _install_entrypoint_dist(monkeypatch, holder) + + assert pm.load_setuptools_entrypoints("hello") == 1 + plugin = pm.get_plugin("myname") + pm.unregister(name="myname") + + pm.register(plugin, name="myname") + assert pm.list_plugin_distributions() == [] + assert pm.list_plugin_distinfo() == [] + + # Reloading from the entry point re-appends exactly one entry. + assert pm.unregister(name="myname") is plugin + assert pm.load_setuptools_entrypoints("hello") == 1 + assert pm.list_plugin_distributions() == [(plugin, holder["dist"])] + + +def test_unregister_by_plugin_object_also_drops_distinfo( + monkeypatch: pytest.MonkeyPatch, pm: PluginManager +) -> None: + """The plugin-object form of unregister clears the bookkeeping too.""" + holder: dict = {"plugin": type("PseudoPlugin", (), {})()} + _install_entrypoint_dist(monkeypatch, holder) + + assert pm.load_setuptools_entrypoints("hello") == 1 + plugin = pm.get_plugin("myname") + + assert pm.unregister(plugin=plugin) is plugin + assert pm.list_plugin_distributions() == [] + assert pm.list_plugin_distinfo() == [] From 10fe30453730f595497b9e122bc29ea48e8c3526 Mon Sep 17 00:00:00 2001 From: openjobs-agent Date: Fri, 16 Oct 2026 17:13:47 +0000 Subject: [PATCH 2/2] docs(changelog): add newsfragment for #763 --- changelog/763.bugfix.rst | 1 + 1 file changed, 1 insertion(+) 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..b5aadec5 --- /dev/null +++ b/changelog/763.bugfix.rst @@ -0,0 +1 @@ +Fix a stale entry in :meth:`~pluggy.PluginManager.list_name_plugin` when a plugin is unregistered with :meth:`~pluggy.PluginManager.unregister`: the distribution metadata recorded by :meth:`~pluggy.PluginManager.register` is now dropped together with the plugin, so re-registering a different plugin under the same name no longer reports the old distribution.