mirror of
https://github.com/python/cpython.git
synced 2025-09-15 13:16:12 +00:00
gh-93259: Validate arg to `Distribution.from_name
`. (GH-94270)
Syncs with importlib_metadata 4.12.0.
This commit is contained in:
parent
9af6b75298
commit
38612a05b5
6 changed files with 135 additions and 67 deletions
|
@ -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
|
||||||
==============================
|
==============================
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
Now raise ``ValueError`` when ``None`` or an empty string are passed to
|
||||||
|
``Distribution.from_name`` (and other callers).
|
Loading…
Add table
Add a link
Reference in a new issue