gh-93259: Validate arg to `Distribution.from_name`. (GH-94270)

Syncs with importlib_metadata 4.12.0.
This commit is contained in:
Jason R. Coombs 2022-06-25 21:04:28 -04:00 committed by GitHub
parent 9af6b75298
commit 38612a05b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 135 additions and 67 deletions

View file

@ -13,13 +13,13 @@
**Source code:** :source:`Lib/importlib/metadata/__init__.py` **Source code:** :source:`Lib/importlib/metadata/__init__.py`
``importlib.metadata`` is a library that provides for access to installed ``importlib.metadata`` is a library that provides access to installed
package metadata. Built in part on Python's import system, this library package metadata, such as its entry points or its
top-level name. Built in part on Python's import system, this library
intends to replace similar functionality in the `entry point intends to replace similar functionality in the `entry point
API`_ and `metadata API`_ of ``pkg_resources``. Along with API`_ and `metadata API`_ of ``pkg_resources``. Along with
:mod:`importlib.resources` (with new features backported to the :mod:`importlib.resources`,
`importlib_resources`_ package), this can eliminate the need to use the older this package can eliminate the need to use the older and less efficient
and less efficient
``pkg_resources`` package. ``pkg_resources`` package.
By "installed package" we generally mean a third-party package installed into 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
anywhere. anywhere.
.. seealso::
https://importlib-metadata.readthedocs.io/
The documentation for ``importlib_metadata``, which supplies a
backport of ``importlib.metadata``.
Overview Overview
======== ========
@ -54,9 +61,9 @@ You can get the version string for ``wheel`` by running the following:
>>> version('wheel') # doctest: +SKIP >>> version('wheel') # doctest: +SKIP
'0.32.3' '0.32.3'
You can also get the set of entry points keyed by group, such as You can also get a collection of entry points selectable by properties of the EntryPoint (typically 'group' or 'name'), such as
``console_scripts``, ``distutils.commands`` and others. Each group contains a ``console_scripts``, ``distutils.commands`` and others. Each group contains a
sequence of :ref:`EntryPoint <entry-points>` objects. collection of :ref:`EntryPoint <entry-points>` objects.
You can get the :ref:`metadata for a distribution <metadata>`:: You can get the :ref:`metadata for a distribution <metadata>`::
@ -91,7 +98,7 @@ Query all entry points::
>>> eps = entry_points() # doctest: +SKIP >>> eps = entry_points() # doctest: +SKIP
The ``entry_points()`` function returns an ``EntryPoints`` object, The ``entry_points()`` function returns an ``EntryPoints`` object,
a sequence of all ``EntryPoint`` objects with ``names`` and ``groups`` a collection of all ``EntryPoint`` objects with ``names`` and ``groups``
attributes for convenience:: attributes for convenience::
>>> sorted(eps.groups) # doctest: +SKIP >>> sorted(eps.groups) # doctest: +SKIP
@ -174,6 +181,13 @@ all the metadata in a JSON-compatible form per :PEP:`566`::
>>> wheel_metadata.json['requires_python'] >>> wheel_metadata.json['requires_python']
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
.. note::
The actual type of the object returned by ``metadata()`` is an
implementation detail and should be accessed only through the interface
described by the
`PackageMetadata protocol <https://importlib-metadata.readthedocs.io/en/latest/api.html#importlib_metadata.PackageMetadata>`.
.. versionchanged:: 3.10 .. versionchanged:: 3.10
The ``Description`` is now included in the metadata when presented The ``Description`` is now included in the metadata when presented
through the payload. Line continuation characters have been removed. 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`
for additional details. for additional details.
Distribution Discovery
======================
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:
- ``importlib.metadata`` does not honor :class:`bytes` objects on ``sys.path``.
- ``importlib.metadata`` will incidentally honor :py:class:`pathlib.Path` objects on ``sys.path`` even though such values will be ignored for imports.
Extending the search algorithm Extending the search algorithm
============================== ==============================

View file

@ -543,7 +543,7 @@ class Distribution:
""" """
@classmethod @classmethod
def from_name(cls, name): def from_name(cls, name: str):
"""Return the Distribution for the given package name. """Return the Distribution for the given package name.
:param name: The name of the distribution package to search for. :param name: The name of the distribution package to search for.
@ -551,13 +551,13 @@ class Distribution:
package, if found. package, if found.
:raises PackageNotFoundError: When the named package's distribution :raises PackageNotFoundError: When the named package's distribution
metadata cannot be found. metadata cannot be found.
:raises ValueError: When an invalid value is supplied for name.
""" """
for resolver in cls._discover_resolvers(): if not name:
dists = resolver(DistributionFinder.Context(name=name)) raise ValueError("A distribution name is required.")
dist = next(iter(dists), None) try:
if dist is not None: return next(cls.discover(name=name))
return dist except StopIteration:
else:
raise PackageNotFoundError(name) raise PackageNotFoundError(name)
@classmethod @classmethod
@ -945,13 +945,26 @@ class PathDistribution(Distribution):
normalized name from the file system path. normalized name from the file system path.
""" """
stem = os.path.basename(str(self._path)) stem = os.path.basename(str(self._path))
return self._name_from_stem(stem) or super()._normalized_name return (
pass_none(Prepared.normalize)(self._name_from_stem(stem))
or super()._normalized_name
)
def _name_from_stem(self, stem): @staticmethod
name, ext = os.path.splitext(stem) def _name_from_stem(stem):
"""
>>> PathDistribution._name_from_stem('foo-3.0.egg-info')
'foo'
>>> PathDistribution._name_from_stem('CherryPy-3.0.dist-info')
'CherryPy'
>>> PathDistribution._name_from_stem('face.egg-info')
'face'
>>> PathDistribution._name_from_stem('foo.bar')
"""
filename, ext = os.path.splitext(stem)
if ext not in ('.dist-info', '.egg-info'): if ext not in ('.dist-info', '.egg-info'):
return return
name, sep, rest = stem.partition('-') name, sep, rest = filename.partition('-')
return name return name
@ -991,6 +1004,15 @@ def version(distribution_name):
return distribution(distribution_name).version return distribution(distribution_name).version
_unique = functools.partial(
unique_everseen,
key=operator.attrgetter('_normalized_name'),
)
"""
Wrapper for ``distributions`` to return unique distributions by name.
"""
def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
"""Return EntryPoint objects for all installed packages. """Return EntryPoint objects for all installed packages.
@ -1008,10 +1030,8 @@ def entry_points(**params) -> Union[EntryPoints, SelectableGroups]:
:return: EntryPoints or SelectableGroups for all installed packages. :return: EntryPoints or SelectableGroups for all installed packages.
""" """
norm_name = operator.attrgetter('_normalized_name')
unique = functools.partial(unique_everseen, key=norm_name)
eps = itertools.chain.from_iterable( eps = itertools.chain.from_iterable(
dist.entry_points for dist in unique(distributions()) dist.entry_points for dist in _unique(distributions())
) )
return SelectableGroups.load(eps).select(**params) return SelectableGroups.load(eps).select(**params)

View file

@ -5,6 +5,7 @@ import shutil
import pathlib import pathlib
import tempfile import tempfile
import textwrap import textwrap
import functools
import contextlib import contextlib
from test.support.os_helper import FS_NONASCII from test.support.os_helper import FS_NONASCII
@ -296,3 +297,18 @@ class ZipFixtures:
# Add self.zip_name to the front of sys.path. # Add self.zip_name to the front of sys.path.
self.resources = contextlib.ExitStack() self.resources = contextlib.ExitStack()
self.addCleanup(self.resources.close) self.addCleanup(self.resources.close)
def parameterize(*args_set):
"""Run test method with a series of parameters."""
def wrapper(func):
@functools.wraps(func)
def _inner(self):
for args in args_set:
with self.subTest(**args):
func(self, **args)
return _inner
return wrapper

View file

@ -1,7 +1,6 @@
import re import re
import json import json
import pickle import pickle
import textwrap
import unittest import unittest
import warnings import warnings
import importlib.metadata import importlib.metadata
@ -16,6 +15,7 @@ from importlib.metadata import (
Distribution, Distribution,
EntryPoint, EntryPoint,
PackageNotFoundError, PackageNotFoundError,
_unique,
distributions, distributions,
entry_points, entry_points,
metadata, metadata,
@ -51,6 +51,14 @@ class BasicTests(fixtures.DistInfoPkg, unittest.TestCase):
def test_new_style_classes(self): def test_new_style_classes(self):
self.assertIsInstance(Distribution, type) self.assertIsInstance(Distribution, type)
@fixtures.parameterize(
dict(name=None),
dict(name=''),
)
def test_invalid_inputs_to_from_name(self, name):
with self.assertRaises(Exception):
Distribution.from_name(name)
class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): class ImportTests(fixtures.DistInfoPkg, unittest.TestCase):
def test_import_nonexistent_module(self): def test_import_nonexistent_module(self):
@ -78,48 +86,50 @@ class ImportTests(fixtures.DistInfoPkg, unittest.TestCase):
class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod @staticmethod
def pkg_with_dashes(site_dir): def make_pkg(name):
""" """
Create minimal metadata for a package with dashes Create minimal metadata for a dist-info package with
in the name (and thus underscores in the filename). the indicated name on the file system.
""" """
metadata_dir = site_dir / 'my_pkg.dist-info' return {
metadata_dir.mkdir() f'{name}.dist-info': {
metadata = metadata_dir / 'METADATA' 'METADATA': 'VERSION: 1.0\n',
with metadata.open('w', encoding='utf-8') as strm: },
strm.write('Version: 1.0\n') }
return 'my-pkg'
def test_dashes_in_dist_name_found_as_underscores(self): def test_dashes_in_dist_name_found_as_underscores(self):
""" """
For a package with a dash in the name, the dist-info metadata For a package with a dash in the name, the dist-info metadata
uses underscores in the name. Ensure the metadata loads. uses underscores in the name. Ensure the metadata loads.
""" """
pkg_name = self.pkg_with_dashes(self.site_dir) fixtures.build_files(self.make_pkg('my_pkg'), self.site_dir)
assert version(pkg_name) == '1.0' assert version('my-pkg') == '1.0'
@staticmethod
def pkg_with_mixed_case(site_dir):
"""
Create minimal metadata for a package with mixed case
in the name.
"""
metadata_dir = site_dir / 'CherryPy.dist-info'
metadata_dir.mkdir()
metadata = metadata_dir / 'METADATA'
with metadata.open('w', encoding='utf-8') as strm:
strm.write('Version: 1.0\n')
return 'CherryPy'
def test_dist_name_found_as_any_case(self): def test_dist_name_found_as_any_case(self):
""" """
Ensure the metadata loads when queried with any case. Ensure the metadata loads when queried with any case.
""" """
pkg_name = self.pkg_with_mixed_case(self.site_dir) pkg_name = 'CherryPy'
fixtures.build_files(self.make_pkg(pkg_name), self.site_dir)
assert version(pkg_name) == '1.0' assert version(pkg_name) == '1.0'
assert version(pkg_name.lower()) == '1.0' assert version(pkg_name.lower()) == '1.0'
assert version(pkg_name.upper()) == '1.0' assert version(pkg_name.upper()) == '1.0'
def test_unique_distributions(self):
"""
Two distributions varying only by non-normalized name on
the file system should resolve as the same.
"""
fixtures.build_files(self.make_pkg('abc'), self.site_dir)
before = list(_unique(distributions()))
alt_site_dir = self.fixtures.enter_context(fixtures.tempdir())
self.fixtures.enter_context(self.add_sys_path(alt_site_dir))
fixtures.build_files(self.make_pkg('ABC'), alt_site_dir)
after = list(_unique(distributions()))
assert len(after) == len(before)
class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod @staticmethod
@ -128,11 +138,12 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
Create minimal metadata for a package with non-ASCII in Create minimal metadata for a package with non-ASCII in
the description. the description.
""" """
metadata_dir = site_dir / 'portend.dist-info' contents = {
metadata_dir.mkdir() 'portend.dist-info': {
metadata = metadata_dir / 'METADATA' 'METADATA': 'Description: pôrˈtend',
with metadata.open('w', encoding='utf-8') as fp: },
fp.write('Description: pôrˈtend') }
fixtures.build_files(contents, site_dir)
return 'portend' return 'portend'
@staticmethod @staticmethod
@ -141,19 +152,15 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
Create minimal metadata for an egg-info package with Create minimal metadata for an egg-info package with
non-ASCII in the description. non-ASCII in the description.
""" """
metadata_dir = site_dir / 'portend.dist-info' contents = {
metadata_dir.mkdir() 'portend.dist-info': {
metadata = metadata_dir / 'METADATA' 'METADATA': """
with metadata.open('w', encoding='utf-8') as fp:
fp.write(
textwrap.dedent(
"""
Name: portend Name: portend
pôrˈtend pôrˈtend""",
""" },
).strip() }
) fixtures.build_files(contents, site_dir)
return 'portend' return 'portend'
def test_metadata_loads(self): def test_metadata_loads(self):

View file

@ -89,15 +89,15 @@ class APITests(
self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg')) self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
self.assertEqual(ep.dist.version, "1.0.0") self.assertEqual(ep.dist.version, "1.0.0")
def test_entry_points_unique_packages(self): def test_entry_points_unique_packages_normalized(self):
""" """
Entry points should only be exposed for the first package Entry points should only be exposed for the first package
on sys.path with a given name. on sys.path with a given name (even when normalized).
""" """
alt_site_dir = self.fixtures.enter_context(fixtures.tempdir()) alt_site_dir = self.fixtures.enter_context(fixtures.tempdir())
self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) self.fixtures.enter_context(self.add_sys_path(alt_site_dir))
alt_pkg = { alt_pkg = {
"distinfo_pkg-1.1.0.dist-info": { "DistInfo_pkg-1.1.0.dist-info": {
"METADATA": """ "METADATA": """
Name: distinfo-pkg Name: distinfo-pkg
Version: 1.1.0 Version: 1.1.0

View file

@ -0,0 +1,2 @@
Now raise ``ValueError`` when ``None`` or an empty string are passed to
``Distribution.from_name`` (and other callers).