Skip to content

Commit 8401cd8

Browse files
committed
gh-93259: Validate arg to Distribution.from_name.
Syncs with importlib_metadata 4.12.0.
1 parent bec802d commit 8401cd8

File tree

6 files changed

+135
-67
lines changed

6 files changed

+135
-67
lines changed

Doc/library/importlib.metadata.rst

+31-8
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313

1414
**Source code:** :source:`Lib/importlib/metadata/__init__.py`
1515

16-
``importlib.metadata`` is a library that provides for access to installed
17-
package metadata. Built in part on Python's import system, this library
16+
``importlib.metadata`` is a library that provides access to installed
17+
package metadata, such as its entry points or its
18+
top-level name. Built in part on Python's import system, this library
1819
intends to replace similar functionality in the `entry point
1920
API`_ and `metadata API`_ of ``pkg_resources``. Along with
20-
:mod:`importlib.resources` (with new features backported to the
21-
`importlib_resources`_ package), this can eliminate the need to use the older
22-
and less efficient
21+
:mod:`importlib.resources`,
22+
this package can eliminate the need to use the older and less efficient
2323
``pkg_resources`` package.
2424

2525
By "installed package" we generally mean a third-party package installed into
@@ -32,6 +32,13 @@ By default, package metadata can live on the file system or in zip archives on
3232
anywhere.
3333

3434

35+
.. seealso::
36+
37+
https://importlib-metadata.readthedocs.io/
38+
The documentation for ``importlib_metadata``, which supplies a
39+
backport of ``importlib.metadata``.
40+
41+
3542
Overview
3643
========
3744

@@ -54,9 +61,9 @@ You can get the version string for ``wheel`` by running the following:
5461
>>> version('wheel') # doctest: +SKIP
5562
'0.32.3'
5663
57-
You can also get the set of entry points keyed by group, such as
64+
You can also get a collection of entry points selectable by properties of the EntryPoint (typically 'group' or 'name'), such as
5865
``console_scripts``, ``distutils.commands`` and others. Each group contains a
59-
sequence of :ref:`EntryPoint <entry-points>` objects.
66+
collection of :ref:`EntryPoint <entry-points>` objects.
6067

6168
You can get the :ref:`metadata for a distribution <metadata>`::
6269

@@ -91,7 +98,7 @@ Query all entry points::
9198
>>> eps = entry_points() # doctest: +SKIP
9299

93100
The ``entry_points()`` function returns an ``EntryPoints`` object,
94-
a sequence of all ``EntryPoint`` objects with ``names`` and ``groups``
101+
a collection of all ``EntryPoint`` objects with ``names`` and ``groups``
95102
attributes for convenience::
96103

97104
>>> sorted(eps.groups) # doctest: +SKIP
@@ -174,6 +181,13 @@ all the metadata in a JSON-compatible form per :PEP:`566`::
174181
>>> wheel_metadata.json['requires_python']
175182
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
176183

184+
.. note::
185+
186+
The actual type of the object returned by ``metadata()`` is an
187+
implementation detail and should be accessed only through the interface
188+
described by the
189+
`PackageMetadata protocol <https://importlib-metadata.readthedocs.io/en/latest/api.html#importlib_metadata.PackageMetadata>`.
190+
177191
.. versionchanged:: 3.10
178192
The ``Description`` is now included in the metadata when presented
179193
through the payload. Line continuation characters have been removed.
@@ -295,6 +309,15 @@ The full set of available metadata is not described here. See :pep:`566`
295309
for additional details.
296310

297311

312+
Distribution Discovery
313+
======================
314+
315+
By default, this package provides built-in support for discovery of metadata for file system and zip file packages. This metadata finder search defaults to ``sys.path``, but varies slightly in how it interprets those values from how other import machinery does. In particular:
316+
317+
- ``importlib.metadata`` does not honor :class:`bytes` objects on ``sys.path``.
318+
- ``importlib.metadata`` will incidentally honor :py:class:`pathlib.Path` objects on ``sys.path`` even though such values will be ignored for imports.
319+
320+
298321
Extending the search algorithm
299322
==============================
300323

Lib/importlib/metadata/__init__.py

+34-14
Original file line numberDiff line numberDiff line change
@@ -543,21 +543,21 @@ def locate_file(self, path):
543543
"""
544544

545545
@classmethod
546-
def from_name(cls, name):
546+
def from_name(cls, name: str):
547547
"""Return the Distribution for the given package name.
548548
549549
:param name: The name of the distribution package to search for.
550550
:return: The Distribution instance (or subclass thereof) for the named
551551
package, if found.
552552
:raises PackageNotFoundError: When the named package's distribution
553553
metadata cannot be found.
554+
:raises ValueError: When an invalid value is supplied for name.
554555
"""
555-
for resolver in cls._discover_resolvers():
556-
dists = resolver(DistributionFinder.Context(name=name))
557-
dist = next(iter(dists), None)
558-
if dist is not None:
559-
return dist
560-
else:
556+
if not name:
557+
raise ValueError("A distribution name is required.")
558+
try:
559+
return next(cls.discover(name=name))
560+
except StopIteration:
561561
raise PackageNotFoundError(name)
562562

563563
@classmethod
@@ -945,13 +945,26 @@ def _normalized_name(self):
945945
normalized name from the file system path.
946946
"""
947947
stem = os.path.basename(str(self._path))
948-
return self._name_from_stem(stem) or super()._normalized_name
948+
return (
949+
pass_none(Prepared.normalize)(self._name_from_stem(stem))
950+
or super()._normalized_name
951+
)
949952

950-
def _name_from_stem(self, stem):
951-
name, ext = os.path.splitext(stem)
953+
@staticmethod
954+
def _name_from_stem(stem):
955+
"""
956+
>>> PathDistribution._name_from_stem('foo-3.0.egg-info')
957+
'foo'
958+
>>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info')
959+
'CherryPy'
960+
>>> PathDistribution._name_from_stem('face.egg-info')
961+
'face'
962+
>>> PathDistribution._name_from_stem('foo.bar')
963+
"""
964+
filename, ext = os.path.splitext(stem)
952965
if ext not in ('.dist-info', '.egg-info'):
953966
return
954-
name, sep, rest = stem.partition('-')
967+
name, sep, rest = filename.partition('-')
955968
return name
956969

957970

@@ -991,6 +1004,15 @@ def version(distribution_name):
9911004
return distribution(distribution_name).version
9921005

9931006

1007+
_unique = functools.partial(
1008+
unique_everseen,
1009+
key=operator.attrgetter('_normalized_name'),
1010+
)
1011+
"""
1012+
Wrapper for ``distributions`` to return unique distributions by name.
1013+
"""
1014+
1015+
9941016
def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
9951017
"""Return EntryPoint objects for all installed packages.
9961018
@@ -1008,10 +1030,8 @@ def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
10081030
10091031
:return: EntryPoints or SelectableGroups for all installed packages.
10101032
"""
1011-
norm_name = operator.attrgetter('_normalized_name')
1012-
unique = functools.partial(unique_everseen, key=norm_name)
10131033
eps = itertools.chain.from_iterable(
1014-
dist.entry_points for dist in unique(distributions())
1034+
dist.entry_points for dist in _unique(distributions())
10151035
)
10161036
return SelectableGroups.load(eps).select(**params)
10171037

Lib/test/test_importlib/fixtures.py

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pathlib
66
import tempfile
77
import textwrap
8+
import functools
89
import contextlib
910

1011
from test.support.os_helper import FS_NONASCII
@@ -296,3 +297,18 @@ def setUp(self):
296297
# Add self.zip_name to the front of sys.path.
297298
self.resources = contextlib.ExitStack()
298299
self.addCleanup(self.resources.close)
300+
301+
302+
def parameterize(*args_set):
303+
"""Run test method with a series of parameters."""
304+
305+
def wrapper(func):
306+
@functools.wraps(func)
307+
def _inner(self):
308+
for args in args_set:
309+
with self.subTest(**args):
310+
func(self, **args)
311+
312+
return _inner
313+
314+
return wrapper

Lib/test/test_importlib/test_main.py

+49-42
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import re
22
import json
33
import pickle
4-
import textwrap
54
import unittest
65
import warnings
76
import importlib.metadata
@@ -16,6 +15,7 @@
1615
Distribution,
1716
EntryPoint,
1817
PackageNotFoundError,
18+
_unique,
1919
distributions,
2020
entry_points,
2121
metadata,
@@ -51,6 +51,14 @@ def test_package_not_found_mentions_metadata(self):
5151
def test_new_style_classes(self):
5252
self.assertIsInstance(Distribution, type)
5353

54+
@fixtures.parameterize(
55+
dict(name=None),
56+
dict(name=''),
57+
)
58+
def test_invalid_inputs_to_from_name(self, name):
59+
with self.assertRaises(Exception):
60+
Distribution.from_name(name)
61+
5462

5563
class ImportTests(fixtures.DistInfoPkg, unittest.TestCase):
5664
def test_import_nonexistent_module(self):
@@ -78,48 +86,50 @@ def test_resolve_without_attr(self):
7886

7987
class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
8088
@staticmethod
81-
def pkg_with_dashes(site_dir):
89+
def make_pkg(name):
8290
"""
83-
Create minimal metadata for a package with dashes
84-
in the name (and thus underscores in the filename).
91+
Create minimal metadata for a dist-info package with
92+
the indicated name on the file system.
8593
"""
86-
metadata_dir = site_dir / 'my_pkg.dist-info'
87-
metadata_dir.mkdir()
88-
metadata = metadata_dir / 'METADATA'
89-
with metadata.open('w', encoding='utf-8') as strm:
90-
strm.write('Version: 1.0\n')
91-
return 'my-pkg'
94+
return {
95+
f'{name}.dist-info': {
96+
'METADATA': 'VERSION: 1.0\n',
97+
},
98+
}
9299

93100
def test_dashes_in_dist_name_found_as_underscores(self):
94101
"""
95102
For a package with a dash in the name, the dist-info metadata
96103
uses underscores in the name. Ensure the metadata loads.
97104
"""
98-
pkg_name = self.pkg_with_dashes(self.site_dir)
99-
assert version(pkg_name) == '1.0'
100-
101-
@staticmethod
102-
def pkg_with_mixed_case(site_dir):
103-
"""
104-
Create minimal metadata for a package with mixed case
105-
in the name.
106-
"""
107-
metadata_dir = site_dir / 'CherryPy.dist-info'
108-
metadata_dir.mkdir()
109-
metadata = metadata_dir / 'METADATA'
110-
with metadata.open('w', encoding='utf-8') as strm:
111-
strm.write('Version: 1.0\n')
112-
return 'CherryPy'
105+
fixtures.build_files(self.make_pkg('my_pkg'), self.site_dir)
106+
assert version('my-pkg') == '1.0'
113107

114108
def test_dist_name_found_as_any_case(self):
115109
"""
116110
Ensure the metadata loads when queried with any case.
117111
"""
118-
pkg_name = self.pkg_with_mixed_case(self.site_dir)
112+
pkg_name = 'CherryPy'
113+
fixtures.build_files(self.make_pkg(pkg_name), self.site_dir)
119114
assert version(pkg_name) == '1.0'
120115
assert version(pkg_name.lower()) == '1.0'
121116
assert version(pkg_name.upper()) == '1.0'
122117

118+
def test_unique_distributions(self):
119+
"""
120+
Two distributions varying only by non-normalized name on
121+
the file system should resolve as the same.
122+
"""
123+
fixtures.build_files(self.make_pkg('abc'), self.site_dir)
124+
before = list(_unique(distributions()))
125+
126+
alt_site_dir = self.fixtures.enter_context(fixtures.tempdir())
127+
self.fixtures.enter_context(self.add_sys_path(alt_site_dir))
128+
fixtures.build_files(self.make_pkg('ABC'), alt_site_dir)
129+
after = list(_unique(distributions()))
130+
131+
assert len(after) == len(before)
132+
123133

124134
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
125135
@staticmethod
@@ -128,11 +138,12 @@ def pkg_with_non_ascii_description(site_dir):
128138
Create minimal metadata for a package with non-ASCII in
129139
the description.
130140
"""
131-
metadata_dir = site_dir / 'portend.dist-info'
132-
metadata_dir.mkdir()
133-
metadata = metadata_dir / 'METADATA'
134-
with metadata.open('w', encoding='utf-8') as fp:
135-
fp.write('Description: pôrˈtend')
141+
contents = {
142+
'portend.dist-info': {
143+
'METADATA': 'Description: pôrˈtend',
144+
},
145+
}
146+
fixtures.build_files(contents, site_dir)
136147
return 'portend'
137148

138149
@staticmethod
@@ -141,19 +152,15 @@ def pkg_with_non_ascii_description_egg_info(site_dir):
141152
Create minimal metadata for an egg-info package with
142153
non-ASCII in the description.
143154
"""
144-
metadata_dir = site_dir / 'portend.dist-info'
145-
metadata_dir.mkdir()
146-
metadata = metadata_dir / 'METADATA'
147-
with metadata.open('w', encoding='utf-8') as fp:
148-
fp.write(
149-
textwrap.dedent(
150-
"""
155+
contents = {
156+
'portend.dist-info': {
157+
'METADATA': """
151158
Name: portend
152159
153-
pôrˈtend
154-
"""
155-
).strip()
156-
)
160+
pôrˈtend""",
161+
},
162+
}
163+
fixtures.build_files(contents, site_dir)
157164
return 'portend'
158165

159166
def test_metadata_loads(self):

Lib/test/test_importlib/test_metadata_api.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ def test_entry_points_distribution(self):
8989
self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
9090
self.assertEqual(ep.dist.version, "1.0.0")
9191

92-
def test_entry_points_unique_packages(self):
92+
def test_entry_points_unique_packages_normalized(self):
9393
"""
9494
Entry points should only be exposed for the first package
95-
on sys.path with a given name.
95+
on sys.path with a given name (even when normalized).
9696
"""
9797
alt_site_dir = self.fixtures.enter_context(fixtures.tempdir())
9898
self.fixtures.enter_context(self.add_sys_path(alt_site_dir))
9999
alt_pkg = {
100-
"distinfo_pkg-1.1.0.dist-info": {
100+
"DistInfo_pkg-1.1.0.dist-info": {
101101
"METADATA": """
102102
Name: distinfo-pkg
103103
Version: 1.1.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Now raise ``ValueError`` when ``None`` or an empty string are passed to
2+
``Distribution.from_name`` (and other callers).

0 commit comments

Comments
 (0)