Skip to content

Commit 5d253ae

Browse files
authored
Merge pull request #553 from python/bugfix/534-entry-points-prefer-valid
Prefer valid distributions when collecting entry points
2 parents 60bbaca + 0aa24f3 commit 5d253ae

6 files changed

Lines changed: 154 additions & 11 deletions

File tree

‎importlib_metadata/__init__.py‎

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,14 @@
2727
from importlib import import_module
2828
from importlib.abc import MetaPathFinder
2929
from itertools import starmap
30-
from typing import Any
30+
from typing import Any, TypeVar
3131

3232
from ._collections import FreezableDefaultDict, Pair
3333
from ._compat import (
3434
NullFinder,
3535
install,
3636
localize,
3737
)
38-
from ._context import ExceptionTrap
3938
from ._functools import apply, compose, method_cache, noop, pass_none, passthrough
4039
from ._itertools import always_iterable, bucket, unique_everseen
4140
from ._meta import (
@@ -67,6 +66,9 @@
6766
]
6867

6968

69+
_D = TypeVar('_D', bound=IDistribution)
70+
71+
7072
class PackageNotFoundError(ModuleNotFoundError):
7173
"""The package was not found."""
7274

@@ -520,18 +522,14 @@ def discover(
520522
)
521523

522524
@staticmethod
523-
def _prefer_valid(dists: Iterable[Distribution]) -> Iterable[Distribution]:
525+
def _prefer_valid(dists: Iterable[_D]) -> Iterable[_D]:
524526
"""
525527
Prefer (move to the front) distributions that have metadata.
526528
527529
Ref python/importlib_resources#489.
528530
"""
529531

530-
has_metadata = ExceptionTrap(MetadataNotFound).passes(
531-
operator.attrgetter('metadata')
532-
)
533-
534-
buckets = bucket(dists, has_metadata)
532+
buckets = bucket(dists, _safe_has_metadata)
535533
return itertools.chain(buckets[True], buckets[False])
536534

537535
@staticmethod
@@ -566,15 +564,37 @@ def metadata(self) -> PackageMetadata:
566564
:raises MetadataNotFound: If no metadata file is present.
567565
"""
568566

569-
text = (
567+
text = self._metadata_text()
568+
return self._assemble_message(self._ensure_metadata_present(text))
569+
570+
def _metadata_text(self) -> str | None:
571+
"""
572+
The metadata text, if any.
573+
574+
Separate from ``metadata`` so that ``_has_metadata`` can name
575+
the same files without repeating them and without paying to
576+
parse what it finds.
577+
"""
578+
return (
570579
self.read_text('METADATA')
571580
or self.read_text('PKG-INFO')
572581
# This last clause is here to support old egg-info files. Its
573582
# effect is to just end up using the PathDistribution's self._path
574583
# (which points to the egg-info file) attribute unchanged.
575584
or self.read_text('')
576585
)
577-
return self._assemble_message(self._ensure_metadata_present(text))
586+
587+
def _has_metadata(self) -> bool:
588+
"""
589+
Whether this distribution supplies metadata.
590+
591+
Reads the metadata, as the interface offers nothing cheaper:
592+
``locate_file`` resolves against the package root and not the
593+
metadata, and every other member loads the metadata or parses
594+
it. A subclass able to detect the metadata without reading it
595+
should override this (python/importlib_metadata#534).
596+
"""
597+
return bool(self._metadata_text())
578598

579599
@staticmethod
580600
def _assemble_message(text: str) -> PackageMetadata:
@@ -1078,6 +1098,24 @@ def read_text(self, filename: str | os.PathLike[str]) -> str | None:
10781098

10791099
read_text.__doc__ = Distribution.read_text.__doc__
10801100

1101+
def _has_metadata(self) -> bool:
1102+
"""
1103+
Detect the metadata file without opening it
1104+
(python/importlib_metadata#534).
1105+
1106+
The last clause is for an old-style egg-info file, whose
1107+
metadata is the distribution's own path; a metadata directory
1108+
is not itself metadata. ``SimplePath`` doesn't name
1109+
``is_file``, but only these legacy paths reach that clause and
1110+
they arrive as a ``pathlib.Path`` or a ``zipfile.Path``, both
1111+
of which supply it.
1112+
"""
1113+
return (
1114+
self._path.joinpath('METADATA').exists()
1115+
or self._path.joinpath('PKG-INFO').exists()
1116+
or self._path.is_file() # type: ignore[attr-defined]
1117+
)
1118+
10811119
def locate_file(self, path: str | os.PathLike[str]) -> SimplePath:
10821120
return self._path.parent / path
10831121

@@ -1148,6 +1186,25 @@ def version(distribution_name: str) -> str:
11481186
return distribution(distribution_name).version
11491187

11501188

1189+
def _safe_has_metadata(dist: IDistribution) -> bool:
1190+
"""
1191+
``dist._has_metadata()``, for a distribution that may not supply it.
1192+
1193+
A provider's own ``Distribution`` subclass derives from
1194+
``importlib.metadata`` and is passed through unadapted (#486), so
1195+
it has none; read its metadata as the default would. It supplies
1196+
no ``_metadata_text`` either, hence the unbound call.
1197+
1198+
Such a distribution cannot be asked for ``metadata`` behind a trap
1199+
instead. The ``MetadataNotFound`` it raises is the one from
1200+
``importlib.metadata``, a different class from this package's,
1201+
which no trap of ours would catch.
1202+
"""
1203+
with suppress(AttributeError):
1204+
return dist._has_metadata() # type: ignore[attr-defined]
1205+
return bool(Distribution._metadata_text(dist)) # type: ignore[arg-type]
1206+
1207+
11511208
_unique = functools.partial(
11521209
unique_everseen,
11531210
key=operator.attrgetter('_normalized_name'),
@@ -1167,7 +1224,8 @@ def entry_points(**params) -> EntryPoints:
11671224
:return: EntryPoints for all installed packages.
11681225
"""
11691226
eps = itertools.chain.from_iterable(
1170-
dist.entry_points for dist in _unique(distributions())
1227+
dist.entry_points
1228+
for dist in _unique(Distribution._prefer_valid(distributions()))
11711229
)
11721230
return EntryPoints(eps).select(**params)
11731231

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
The preference for valid distributions no longer propagates the
2+
``MetadataNotFound`` of a distribution that a provider derived from
3+
``importlib.metadata``, whose exception is a different class from this
4+
package's and so escaped the preference rather than marking the distribution
5+
invalid.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Detecting whether a distribution supplies metadata no longer reads or parses
2+
that metadata where the metadata directory can be inspected instead, so
3+
retrieving a distribution by name, which prefers the valid candidates, is
4+
substantially faster.

‎newsfragments/534.bugfix.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
``entry_points()`` now prefers distributions whose metadata is readable, so
2+
an empty metadata directory left behind by an unclean upgrade no longer
3+
shadows the valid one of the same name and suppresses its entry points.
4+
Invalid distributions are deferred to the end of the enumeration rather than
5+
dropped.

‎tests/compat/test_localize.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ def test_conforming_provider_is_not_warned_about(self):
119119
assert distribution('distinfo-pkg').version == '1.0.0'
120120
assert not [str(warning.message) for warning in caught]
121121

122+
def test_dist_without_metadata_is_deferred(self):
123+
"""
124+
A provider's own distribution supplies no detection of its own,
125+
so the metadata is sought through ``read_text``. Asking for
126+
``metadata`` instead would raise, as the ``MetadataNotFound``
127+
of a distribution deriving from ``importlib.metadata`` is a
128+
different class.
129+
"""
130+
131+
class Empty(stdlib.Distribution):
132+
def read_text(self, filename):
133+
return None
134+
135+
def locate_file(self, path):
136+
return None
137+
138+
assert not importlib_metadata._safe_has_metadata(Empty())
139+
122140

123141
class ConformanceCheckTests(unittest.TestCase):
124142
@unittest.skipIf(

‎tests/test_main.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@ def test_abc_enforced(self):
5656
with self.assertRaises(TypeError):
5757
type('DistributionSubclass', (Distribution,), {})()
5858

59+
@fixtures.parameterize(
60+
dict(text='Name: foo\nVersion: 1.0\n', expected=True),
61+
dict(text=None, expected=False),
62+
)
63+
def test_detects_metadata_by_reading(self, text, expected):
64+
"""
65+
A distribution that isn't path-based takes the default
66+
detection, which reads what it cannot otherwise detect.
67+
"""
68+
69+
class Provided(Distribution):
70+
def read_text(self, filename):
71+
return text if filename == 'METADATA' else None
72+
73+
def locate_file(self, path):
74+
return None # pragma: no cover
75+
76+
assert Provided()._has_metadata() is expected
77+
5978
@fixtures.parameterize(
6079
dict(name=None),
6180
dict(name=''),
@@ -160,6 +179,40 @@ def test_valid_dists_preferred(self):
160179
dist = Distribution.from_name('foo')
161180
assert dist.version == "1.0"
162181

182+
def test_invalid_dists_deferred(self):
183+
"""
184+
Preferring the valid dists defers the invalid ones to the end
185+
rather than dropping them.
186+
"""
187+
fixtures.build_files(self.make_pkg('foo-4.0', files={}), self.site_dir)
188+
fixtures.build_files(self.make_pkg('foo-4.1'), self.site_dir)
189+
dists = list(Distribution._prefer_valid(Distribution.discover(name='foo')))
190+
assert len(dists) == 2
191+
assert dists[0].version == "1.0"
192+
193+
def test_entry_points_from_valid_dist(self):
194+
"""
195+
Entry points come from the valid dist even when an invalid one
196+
of the same name is discovered first.
197+
198+
Ref python/importlib_metadata#534.
199+
"""
200+
# as in test_valid_dists_preferred, bracket the valid dist so that
201+
# on most file systems it is never naturally first.
202+
fixtures.build_files(self.make_pkg('foo-4.0', files={}), self.site_dir)
203+
fixtures.build_files(
204+
self.make_pkg(
205+
'foo-4.1',
206+
files={
207+
'METADATA': "VERSION: 1.0",
208+
'entry_points.txt': '[shadowed]\nep = foo:main\n',
209+
},
210+
),
211+
self.site_dir,
212+
)
213+
fixtures.build_files(self.make_pkg('foo-4.2', files={}), self.site_dir)
214+
assert entry_points(group='shadowed').names == {'ep'}
215+
163216
def test_missing_metadata(self):
164217
"""
165218
Dists with a missing metadata file should raise ``MetadataNotFound``.

0 commit comments

Comments
 (0)