From d011a08b9a016f0294b85baf7e7127474ead57a4 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 28 Nov 2025 21:38:58 +0100 Subject: [PATCH] Add list_plugin_distributions() for direct importlib.metadata access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move legacy DistFacade and setuptools compat code to _compat module. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/pluggy/_compat.py | 50 +++++++++++++++++++++++++++++++++++ src/pluggy/_manager.py | 49 +++++++++++++++++++--------------- testing/test_details.py | 2 +- testing/test_pluginmanager.py | 19 ++++++++----- 4 files changed, 90 insertions(+), 30 deletions(-) create mode 100644 src/pluggy/_compat.py diff --git a/src/pluggy/_compat.py b/src/pluggy/_compat.py new file mode 100644 index 00000000..0d86c0ed --- /dev/null +++ b/src/pluggy/_compat.py @@ -0,0 +1,50 @@ +""" +Compatibility layer for legacy setuptools/pkg_resources API. + +This module provides backward compatibility wrappers around modern +importlib.metadata, allowing gradual migration away from setuptools. +""" + +from __future__ import annotations + +import importlib.metadata +from typing import Any + + +class DistFacade: + """Facade providing pkg_resources.Distribution-like interface. + + This class wraps importlib.metadata.Distribution to provide a + compatibility layer for code expecting the legacy pkg_resources API. + The primary difference is the ``project_name`` attribute which + pkg_resources provided but importlib.metadata.Distribution does not. + """ + + __slots__ = ("_dist",) + + def __init__(self, dist: importlib.metadata.Distribution) -> None: + self._dist = dist + + @property + def project_name(self) -> str: + """Get the project name (for pkg_resources compatibility). + + This is equivalent to dist.metadata["name"] but matches the + pkg_resources.Distribution.project_name attribute. + """ + name: str = self.metadata["name"] + return name + + def __getattr__(self, attr: str) -> Any: + """Delegate all other attributes to the wrapped Distribution.""" + return getattr(self._dist, attr) + + def __dir__(self) -> list[str]: + """List available attributes including facade additions.""" + return sorted(dir(self._dist) + ["_dist", "project_name"]) + + def __eq__(self, other: object) -> bool: + """Compare DistFacade instances by their wrapped Distribution.""" + if isinstance(other, DistFacade): + return self._dist == other._dist + return NotImplemented diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 1b994f25..81f2c518 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -29,9 +29,10 @@ if TYPE_CHECKING: - # importtlib.metadata import is slow, defer it. import importlib.metadata + from ._compat import DistFacade + _BeforeTrace: TypeAlias = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None] _AfterTrace: TypeAlias = Callable[ @@ -62,24 +63,6 @@ def __init__(self, plugin: _Plugin, message: str) -> None: self.plugin = plugin -class DistFacade: - """Emulate a pkg_resources Distribution""" - - def __init__(self, dist: importlib.metadata.Distribution) -> None: - self._dist = dist - - @property - def project_name(self) -> str: - name: str = self.metadata["name"] - return name - - def __getattr__(self, attr: str, default: Any | None = None) -> Any: - return getattr(self._dist, attr, default) - - def __dir__(self) -> list[str]: - return sorted(dir(self._dist) + ["_dist", "project_name"]) - - class PluginManager: """Core class which manages registration of plugin objects and 1:N hook calling. @@ -101,7 +84,9 @@ 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, DistFacade]]] = [] + self._plugin_distinfo: Final[ + list[tuple[_Plugin, importlib.metadata.Distribution]] + ] = [] #: The "hook relay", used to call a hook on all registered plugins. #: See :ref:`calling`. self.hook: Final = HookRelay() @@ -418,13 +403,33 @@ 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, DistFacade(dist))) + self._plugin_distinfo.append((plugin, dist)) count += 1 return count def list_plugin_distinfo(self) -> list[tuple[_Plugin, DistFacade]]: """Return a list of (plugin, distinfo) pairs for all - setuptools-registered plugins.""" + setuptools-registered plugins. + + .. note:: + The distinfo objects are wrapped with :class:`~pluggy._compat.DistFacade` + for backward compatibility with the legacy pkg_resources API. + Use the modern :meth:`list_plugin_distributions` method to get + unwrapped :class:`importlib.metadata.Distribution` objects. + """ + + from ._compat import DistFacade + + return [(plugin, DistFacade(dist)) for plugin, dist in self._plugin_distinfo] + + def list_plugin_distributions( + self, + ) -> list[tuple[_Plugin, importlib.metadata.Distribution]]: + """Return a list of (plugin, distribution) pairs for all plugins + loaded via entry points. + + .. versionadded:: 1.7 + """ return list(self._plugin_distinfo) def list_name_plugin(self) -> list[tuple[str, _Plugin]]: diff --git a/testing/test_details.py b/testing/test_details.py index de79d536..ec99f3b5 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -197,7 +197,7 @@ def myhook(self): def test_dist_facade_list_attributes() -> None: - from pluggy._manager import DistFacade + from pluggy._compat import DistFacade fc = DistFacade(distribution("pluggy")) res = dir(fc) diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index f80b1b55..7a1db03d 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -4,6 +4,7 @@ import importlib.metadata from typing import Any +from typing import cast import pytest @@ -12,6 +13,7 @@ from pluggy import HookspecMarker from pluggy import PluginManager from pluggy import PluginValidationError +from pluggy._compat import DistFacade hookspec = HookspecMarker("example") @@ -583,7 +585,9 @@ class NoHooks: pm.add_hookspecs(NoHooks) -def test_load_setuptools_instantiation(monkeypatch, pm: PluginManager) -> None: +def test_load_setuptools_instantiation( + monkeypatch: pytest.MonkeyPatch, pm: PluginManager +) -> None: class EntryPoint: name = "myname" group = "hello" @@ -598,7 +602,8 @@ class PseudoPlugin: class Distribution: entry_points = (EntryPoint(),) - dist = Distribution() + # Cast mock Distribution to satisfy mypy type checking + dist = cast(importlib.metadata.Distribution, Distribution()) def my_distributions(): return (dist,) @@ -610,14 +615,14 @@ def my_distributions(): assert plugin is not None assert plugin.x == 42 ret = pm.list_plugin_distinfo() - # poor man's `assert ret == [(plugin, mock.ANY)]` - assert len(ret) == 1 - assert len(ret[0]) == 2 - assert ret[0][0] == plugin - assert ret[0][1]._dist == dist # type: ignore[comparison-overlap] + assert ret == [(plugin, DistFacade(dist))] num = pm.load_setuptools_entrypoints("hello") assert num == 0 # no plugin loaded by this call + # Test the new modern API returns unwrapped Distribution objects + ret_modern = pm.list_plugin_distributions() + assert ret_modern == [(plugin, dist)] + def test_add_tracefuncs(he_pm: PluginManager) -> None: out: list[Any] = []