Skip to content

Commit d011a08

Browse files
Add list_plugin_distributions() for direct importlib.metadata access
Move legacy DistFacade and setuptools compat code to _compat module. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent dbe2131 commit d011a08

File tree

4 files changed

+90
-30
lines changed

4 files changed

+90
-30
lines changed

src/pluggy/_compat.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Compatibility layer for legacy setuptools/pkg_resources API.
3+
4+
This module provides backward compatibility wrappers around modern
5+
importlib.metadata, allowing gradual migration away from setuptools.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import importlib.metadata
11+
from typing import Any
12+
13+
14+
class DistFacade:
15+
"""Facade providing pkg_resources.Distribution-like interface.
16+
17+
This class wraps importlib.metadata.Distribution to provide a
18+
compatibility layer for code expecting the legacy pkg_resources API.
19+
The primary difference is the ``project_name`` attribute which
20+
pkg_resources provided but importlib.metadata.Distribution does not.
21+
"""
22+
23+
__slots__ = ("_dist",)
24+
25+
def __init__(self, dist: importlib.metadata.Distribution) -> None:
26+
self._dist = dist
27+
28+
@property
29+
def project_name(self) -> str:
30+
"""Get the project name (for pkg_resources compatibility).
31+
32+
This is equivalent to dist.metadata["name"] but matches the
33+
pkg_resources.Distribution.project_name attribute.
34+
"""
35+
name: str = self.metadata["name"]
36+
return name
37+
38+
def __getattr__(self, attr: str) -> Any:
39+
"""Delegate all other attributes to the wrapped Distribution."""
40+
return getattr(self._dist, attr)
41+
42+
def __dir__(self) -> list[str]:
43+
"""List available attributes including facade additions."""
44+
return sorted(dir(self._dist) + ["_dist", "project_name"])
45+
46+
def __eq__(self, other: object) -> bool:
47+
"""Compare DistFacade instances by their wrapped Distribution."""
48+
if isinstance(other, DistFacade):
49+
return self._dist == other._dist
50+
return NotImplemented

src/pluggy/_manager.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929

3030

3131
if TYPE_CHECKING:
32-
# importtlib.metadata import is slow, defer it.
3332
import importlib.metadata
3433

34+
from ._compat import DistFacade
35+
3536

3637
_BeforeTrace: TypeAlias = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None]
3738
_AfterTrace: TypeAlias = Callable[
@@ -62,24 +63,6 @@ def __init__(self, plugin: _Plugin, message: str) -> None:
6263
self.plugin = plugin
6364

6465

65-
class DistFacade:
66-
"""Emulate a pkg_resources Distribution"""
67-
68-
def __init__(self, dist: importlib.metadata.Distribution) -> None:
69-
self._dist = dist
70-
71-
@property
72-
def project_name(self) -> str:
73-
name: str = self.metadata["name"]
74-
return name
75-
76-
def __getattr__(self, attr: str, default: Any | None = None) -> Any:
77-
return getattr(self._dist, attr, default)
78-
79-
def __dir__(self) -> list[str]:
80-
return sorted(dir(self._dist) + ["_dist", "project_name"])
81-
82-
8366
class PluginManager:
8467
"""Core class which manages registration of plugin objects and 1:N hook
8568
calling.
@@ -101,7 +84,9 @@ def __init__(self, project_name: str) -> None:
10184
#: The project name.
10285
self.project_name: Final = project_name
10386
self._name2plugin: Final[dict[str, _Plugin]] = {}
104-
self._plugin_distinfo: Final[list[tuple[_Plugin, DistFacade]]] = []
87+
self._plugin_distinfo: Final[
88+
list[tuple[_Plugin, importlib.metadata.Distribution]]
89+
] = []
10590
#: The "hook relay", used to call a hook on all registered plugins.
10691
#: See :ref:`calling`.
10792
self.hook: Final = HookRelay()
@@ -418,13 +403,33 @@ def load_setuptools_entrypoints(self, group: str, name: str | None = None) -> in
418403
continue
419404
plugin = ep.load()
420405
self.register(plugin, name=ep.name)
421-
self._plugin_distinfo.append((plugin, DistFacade(dist)))
406+
self._plugin_distinfo.append((plugin, dist))
422407
count += 1
423408
return count
424409

425410
def list_plugin_distinfo(self) -> list[tuple[_Plugin, DistFacade]]:
426411
"""Return a list of (plugin, distinfo) pairs for all
427-
setuptools-registered plugins."""
412+
setuptools-registered plugins.
413+
414+
.. note::
415+
The distinfo objects are wrapped with :class:`~pluggy._compat.DistFacade`
416+
for backward compatibility with the legacy pkg_resources API.
417+
Use the modern :meth:`list_plugin_distributions` method to get
418+
unwrapped :class:`importlib.metadata.Distribution` objects.
419+
"""
420+
421+
from ._compat import DistFacade
422+
423+
return [(plugin, DistFacade(dist)) for plugin, dist in self._plugin_distinfo]
424+
425+
def list_plugin_distributions(
426+
self,
427+
) -> list[tuple[_Plugin, importlib.metadata.Distribution]]:
428+
"""Return a list of (plugin, distribution) pairs for all plugins
429+
loaded via entry points.
430+
431+
.. versionadded:: 1.7
432+
"""
428433
return list(self._plugin_distinfo)
429434

430435
def list_name_plugin(self) -> list[tuple[str, _Plugin]]:

testing/test_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def myhook(self):
197197

198198

199199
def test_dist_facade_list_attributes() -> None:
200-
from pluggy._manager import DistFacade
200+
from pluggy._compat import DistFacade
201201

202202
fc = DistFacade(distribution("pluggy"))
203203
res = dir(fc)

testing/test_pluginmanager.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import importlib.metadata
66
from typing import Any
7+
from typing import cast
78

89
import pytest
910

@@ -12,6 +13,7 @@
1213
from pluggy import HookspecMarker
1314
from pluggy import PluginManager
1415
from pluggy import PluginValidationError
16+
from pluggy._compat import DistFacade
1517

1618

1719
hookspec = HookspecMarker("example")
@@ -583,7 +585,9 @@ class NoHooks:
583585
pm.add_hookspecs(NoHooks)
584586

585587

586-
def test_load_setuptools_instantiation(monkeypatch, pm: PluginManager) -> None:
588+
def test_load_setuptools_instantiation(
589+
monkeypatch: pytest.MonkeyPatch, pm: PluginManager
590+
) -> None:
587591
class EntryPoint:
588592
name = "myname"
589593
group = "hello"
@@ -598,7 +602,8 @@ class PseudoPlugin:
598602
class Distribution:
599603
entry_points = (EntryPoint(),)
600604

601-
dist = Distribution()
605+
# Cast mock Distribution to satisfy mypy type checking
606+
dist = cast(importlib.metadata.Distribution, Distribution())
602607

603608
def my_distributions():
604609
return (dist,)
@@ -610,14 +615,14 @@ def my_distributions():
610615
assert plugin is not None
611616
assert plugin.x == 42
612617
ret = pm.list_plugin_distinfo()
613-
# poor man's `assert ret == [(plugin, mock.ANY)]`
614-
assert len(ret) == 1
615-
assert len(ret[0]) == 2
616-
assert ret[0][0] == plugin
617-
assert ret[0][1]._dist == dist # type: ignore[comparison-overlap]
618+
assert ret == [(plugin, DistFacade(dist))]
618619
num = pm.load_setuptools_entrypoints("hello")
619620
assert num == 0 # no plugin loaded by this call
620621

622+
# Test the new modern API returns unwrapped Distribution objects
623+
ret_modern = pm.list_plugin_distributions()
624+
assert ret_modern == [(plugin, dist)]
625+
621626

622627
def test_add_tracefuncs(he_pm: PluginManager) -> None:
623628
out: list[Any] = []

0 commit comments

Comments
 (0)