Compare commits

..

5 Commits

Author SHA1 Message Date
Raymond Hettinger c8a7b8fa1b
bpo-42781: Document the mechanics of cached_property from a user viewpoint (GH-24031) 2020-12-31 17:05:58 -08:00
Jason R. Coombs b5711c940f
bpo-37193: Remove thread objects which finished process its request (GH-23127)
This reverts commit aca67da4fe.
2020-12-31 20:19:30 +00:00
Tao He 3631d6deab
Fixes a typo in importlib.metadata. (#23921)
Signed-off-by: Tao He <sighingnow@gmail.com>
2020-12-31 11:37:53 -08:00
Jason R. Coombs a6fd0f414c
bpo-42163, bpo-42189, bpo-42659: Support uname_tuple._replace (for all but processor) (#23010)
* Add test capturing missed expectation with uname_result._replace.

* bpo-42163: Override uname_result._make to allow uname_result._replace to work (for everything but 'processor'.

* Replace hard-coded length with one derived from the definition.

* Add test capturing missed expectation with copy/deepcopy on namedtuple (bpo-42189).

* bpo-42189: Exclude processor parameter when constructing uname_result.

* In _make, rely on __new__ to strip processor.

* Add blurb.

* iter is not necessary here.

* Rely on num_fields in __new__

* Add test for slices on uname

* Add test for copy and pickle.

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>

* import pickle

* Fix equality test after pickling.

* Simply rely on __reduce__ for pickling.

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
2020-12-31 14:08:03 -05:00
Jason R. Coombs dfdca85dfa
bpo-42382: In importlib.metadata, `EntryPoint` objects now expose `dist` (#23758)
* bpo-42382: In importlib.metadata, `EntryPoint` objects now expose a `.dist` object referencing the `Distribution` when constructed from a `Distribution`.

Also, sync importlib_metadata 3.3:

- Add support for package discovery under package normalization rules.
- The object returned by `metadata()` now has a formally-defined protocol called `PackageMetadata` with declared support for the `.get_all()` method.

* Add blurb

* Remove latent footnote.
2020-12-31 12:56:43 -05:00
14 changed files with 416 additions and 174 deletions

View File

@ -62,16 +62,26 @@ The :mod:`functools` module defines the following functions:
Example:: Example::
class DataSet: class DataSet:
def __init__(self, sequence_of_numbers): def __init__(self, sequence_of_numbers):
self._data = sequence_of_numbers self._data = tuple(sequence_of_numbers)
@cached_property @cached_property
def stdev(self): def stdev(self):
return statistics.stdev(self._data) return statistics.stdev(self._data)
@cached_property The mechanics of :func:`cached_property` are somewhat different from
def variance(self): :func:`property`. A regular property blocks attribute writes unless a
return statistics.variance(self._data) setter is defined. In contrast, a *cached_property* allows writes.
The *cached_property* decorator only runs on lookups and only when an
attribute of the same name doesn't exist. When it does run, the
*cached_property* writes to the attribute with the same name. Subsequent
attribute reads and writes take precedence over the *cached_property*
method and it works like a normal attribute.
The cached value can be cleared by deleting the attribute. This
allows the *cached_property* method to run again.
Note, this decorator interferes with the operation of :pep:`412` Note, this decorator interferes with the operation of :pep:`412`
key-sharing dictionaries. This means that instance dictionaries key-sharing dictionaries. This means that instance dictionaries

View File

@ -115,8 +115,9 @@ Every distribution includes some metadata, which you can extract using the
>>> wheel_metadata = metadata('wheel') # doctest: +SKIP >>> wheel_metadata = metadata('wheel') # doctest: +SKIP
The keys of the returned data structure [#f1]_ name the metadata keywords, and The keys of the returned data structure, a ``PackageMetadata``,
their values are returned unparsed from the distribution metadata:: name the metadata keywords, and
the values are returned unparsed from the distribution metadata::
>>> wheel_metadata['Requires-Python'] # doctest: +SKIP >>> wheel_metadata['Requires-Python'] # doctest: +SKIP
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
@ -206,9 +207,9 @@ Thus, an alternative way to get the version number is through the
There are all kinds of additional metadata available on the ``Distribution`` There are all kinds of additional metadata available on the ``Distribution``
instance:: instance::
>>> d.metadata['Requires-Python'] # doctest: +SKIP >>> dist.metadata['Requires-Python'] # doctest: +SKIP
'>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*' '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*'
>>> d.metadata['License'] # doctest: +SKIP >>> dist.metadata['License'] # doctest: +SKIP
'MIT' 'MIT'
The full set of available metadata is not described here. See :pep:`566` The full set of available metadata is not described here. See :pep:`566`
@ -259,9 +260,3 @@ a custom finder, return instances of this derived ``Distribution`` in the
.. rubric:: Footnotes .. rubric:: Footnotes
.. [#f1] Technically, the returned distribution metadata object is an
:class:`email.message.EmailMessage`
instance, but this is an implementation detail, and not part of the
stable API. You should only use dictionary-like methods and syntax
to access the metadata contents.

View File

@ -1,4 +1,3 @@
import io
import os import os
import re import re
import abc import abc
@ -18,6 +17,7 @@ from contextlib import suppress
from importlib import import_module from importlib import import_module
from importlib.abc import MetaPathFinder from importlib.abc import MetaPathFinder
from itertools import starmap from itertools import starmap
from typing import Any, List, Optional, Protocol, TypeVar, Union
__all__ = [ __all__ = [
@ -43,7 +43,7 @@ class PackageNotFoundError(ModuleNotFoundError):
@property @property
def name(self): def name(self):
name, = self.args (name,) = self.args
return name return name
@ -77,6 +77,8 @@ class EntryPoint(
following the attr, and following any extras. following the attr, and following any extras.
""" """
dist: Optional['Distribution'] = None
def load(self): def load(self):
"""Load the entry point from its definition. If only a module """Load the entry point from its definition. If only a module
is indicated by the value, return that module. Otherwise, is indicated by the value, return that module. Otherwise,
@ -104,23 +106,27 @@ class EntryPoint(
@classmethod @classmethod
def _from_config(cls, config): def _from_config(cls, config):
return [ return (
cls(name, value, group) cls(name, value, group)
for group in config.sections() for group in config.sections()
for name, value in config.items(group) for name, value in config.items(group)
] )
@classmethod @classmethod
def _from_text(cls, text): def _from_text(cls, text):
config = ConfigParser(delimiters='=') config = ConfigParser(delimiters='=')
# case sensitive: https://stackoverflow.com/q/1611799/812183 # case sensitive: https://stackoverflow.com/q/1611799/812183
config.optionxform = str config.optionxform = str
try:
config.read_string(text) config.read_string(text)
except AttributeError: # pragma: nocover return cls._from_config(config)
# Python 2 has no read_string
config.readfp(io.StringIO(text)) @classmethod
return EntryPoint._from_config(config) def _from_text_for(cls, text, dist):
return (ep._for(dist) for ep in cls._from_text(text))
def _for(self, dist):
self.dist = dist
return self
def __iter__(self): def __iter__(self):
""" """
@ -159,6 +165,25 @@ class FileHash:
return '<FileHash mode: {} value: {}>'.format(self.mode, self.value) return '<FileHash mode: {} value: {}>'.format(self.mode, self.value)
_T = TypeVar("_T")
class PackageMetadata(Protocol):
def __len__(self) -> int:
... # pragma: no cover
def __contains__(self, item: str) -> bool:
... # pragma: no cover
def __getitem__(self, key: str) -> str:
... # pragma: no cover
def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]:
"""
Return all values associated with a possibly multi-valued key.
"""
class Distribution: class Distribution:
"""A Python distribution package.""" """A Python distribution package."""
@ -210,8 +235,7 @@ class Distribution:
raise ValueError("cannot accept context and kwargs") raise ValueError("cannot accept context and kwargs")
context = context or DistributionFinder.Context(**kwargs) context = context or DistributionFinder.Context(**kwargs)
return itertools.chain.from_iterable( return itertools.chain.from_iterable(
resolver(context) resolver(context) for resolver in cls._discover_resolvers()
for resolver in cls._discover_resolvers()
) )
@staticmethod @staticmethod
@ -227,14 +251,14 @@ class Distribution:
def _discover_resolvers(): def _discover_resolvers():
"""Search the meta_path for resolvers.""" """Search the meta_path for resolvers."""
declared = ( declared = (
getattr(finder, 'find_distributions', None) getattr(finder, 'find_distributions', None) for finder in sys.meta_path
for finder in sys.meta_path
) )
return filter(None, declared) return filter(None, declared)
@classmethod @classmethod
def _local(cls, root='.'): def _local(cls, root='.'):
from pep517 import build, meta from pep517 import build, meta
system = build.compat_system(root) system = build.compat_system(root)
builder = functools.partial( builder = functools.partial(
meta.build, meta.build,
@ -244,7 +268,7 @@ class Distribution:
return PathDistribution(zipfile.Path(meta.build_as_zip(builder))) return PathDistribution(zipfile.Path(meta.build_as_zip(builder)))
@property @property
def metadata(self): def metadata(self) -> PackageMetadata:
"""Return the parsed metadata for this Distribution. """Return the parsed metadata for this Distribution.
The returned object will have keys that name the various bits of The returned object will have keys that name the various bits of
@ -260,6 +284,11 @@ class Distribution:
) )
return email.message_from_string(text) return email.message_from_string(text)
@property
def name(self):
"""Return the 'Name' metadata for the distribution package."""
return self.metadata['Name']
@property @property
def version(self): def version(self):
"""Return the 'Version' metadata for the distribution package.""" """Return the 'Version' metadata for the distribution package."""
@ -267,7 +296,7 @@ class Distribution:
@property @property
def entry_points(self): def entry_points(self):
return EntryPoint._from_text(self.read_text('entry_points.txt')) return list(EntryPoint._from_text_for(self.read_text('entry_points.txt'), self))
@property @property
def files(self): def files(self):
@ -324,8 +353,9 @@ class Distribution:
section_pairs = cls._read_sections(source.splitlines()) section_pairs = cls._read_sections(source.splitlines())
sections = { sections = {
section: list(map(operator.itemgetter('line'), results)) section: list(map(operator.itemgetter('line'), results))
for section, results in for section, results in itertools.groupby(
itertools.groupby(section_pairs, operator.itemgetter('section')) section_pairs, operator.itemgetter('section')
)
} }
return cls._convert_egg_info_reqs_to_simple_reqs(sections) return cls._convert_egg_info_reqs_to_simple_reqs(sections)
@ -350,6 +380,7 @@ class Distribution:
requirement. This method converts the former to the requirement. This method converts the former to the
latter. See _test_deps_from_requires_text for an example. latter. See _test_deps_from_requires_text for an example.
""" """
def make_condition(name): def make_condition(name):
return name and 'extra == "{name}"'.format(name=name) return name and 'extra == "{name}"'.format(name=name)
@ -438,48 +469,69 @@ class FastPath:
names = zip_path.root.namelist() names = zip_path.root.namelist()
self.joinpath = zip_path.joinpath self.joinpath = zip_path.joinpath
return dict.fromkeys( return dict.fromkeys(child.split(posixpath.sep, 1)[0] for child in names)
child.split(posixpath.sep, 1)[0]
for child in names
)
def is_egg(self, search):
base = self.base
return (
base == search.versionless_egg_name
or base.startswith(search.prefix)
and base.endswith('.egg'))
def search(self, name): def search(self, name):
for child in self.children(): return (
n_low = child.lower() self.joinpath(child)
if (n_low in name.exact_matches for child in self.children()
or n_low.startswith(name.prefix) if name.matches(child, self.base)
and n_low.endswith(name.suffixes) )
# legacy case:
or self.is_egg(name) and n_low == 'egg-info'):
yield self.joinpath(child)
class Prepared: class Prepared:
""" """
A prepared search for metadata on a possibly-named package. A prepared search for metadata on a possibly-named package.
""" """
normalized = ''
prefix = '' normalized = None
suffixes = '.dist-info', '.egg-info' suffixes = '.dist-info', '.egg-info'
exact_matches = [''][:0] exact_matches = [''][:0]
versionless_egg_name = ''
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
if name is None: if name is None:
return return
self.normalized = name.lower().replace('-', '_') self.normalized = self.normalize(name)
self.prefix = self.normalized + '-' self.exact_matches = [self.normalized + suffix for suffix in self.suffixes]
self.exact_matches = [
self.normalized + suffix for suffix in self.suffixes] @staticmethod
self.versionless_egg_name = self.normalized + '.egg' def normalize(name):
"""
PEP 503 normalization plus dashes as underscores.
"""
return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_')
@staticmethod
def legacy_normalize(name):
"""
Normalize the package name as found in the convention in
older packaging tools versions and specs.
"""
return name.lower().replace('-', '_')
def matches(self, cand, base):
low = cand.lower()
pre, ext = os.path.splitext(low)
name, sep, rest = pre.partition('-')
return (
low in self.exact_matches
or ext in self.suffixes
and (not self.normalized or name.replace('.', '_') == self.normalized)
# legacy case:
or self.is_egg(base)
and low == 'egg-info'
)
def is_egg(self, base):
normalized = self.legacy_normalize(self.name or '')
prefix = normalized + '-' if normalized else ''
versionless_egg_name = normalized + '.egg' if self.name else ''
return (
base == versionless_egg_name
or base.startswith(prefix)
and base.endswith('.egg')
)
class MetadataPathFinder(DistributionFinder): class MetadataPathFinder(DistributionFinder):
@ -500,8 +552,7 @@ class MetadataPathFinder(DistributionFinder):
def _search_paths(cls, name, paths): def _search_paths(cls, name, paths):
"""Find metadata directories in paths heuristically.""" """Find metadata directories in paths heuristically."""
return itertools.chain.from_iterable( return itertools.chain.from_iterable(
path.search(Prepared(name)) path.search(Prepared(name)) for path in map(FastPath, paths)
for path in map(FastPath, paths)
) )
@ -515,9 +566,15 @@ class PathDistribution(Distribution):
self._path = path self._path = path
def read_text(self, filename): def read_text(self, filename):
with suppress(FileNotFoundError, IsADirectoryError, KeyError, with suppress(
NotADirectoryError, PermissionError): FileNotFoundError,
IsADirectoryError,
KeyError,
NotADirectoryError,
PermissionError,
):
return self._path.joinpath(filename).read_text(encoding='utf-8') return self._path.joinpath(filename).read_text(encoding='utf-8')
read_text.__doc__ = Distribution.read_text.__doc__ read_text.__doc__ = Distribution.read_text.__doc__
def locate_file(self, path): def locate_file(self, path):
@ -541,11 +598,11 @@ def distributions(**kwargs):
return Distribution.discover(**kwargs) return Distribution.discover(**kwargs)
def metadata(distribution_name): def metadata(distribution_name) -> PackageMetadata:
"""Get the metadata for the named package. """Get the metadata for the named package.
:param distribution_name: The name of the distribution package to query. :param distribution_name: The name of the distribution package to query.
:return: An email.Message containing the parsed metadata. :return: A PackageMetadata containing the parsed metadata.
""" """
return Distribution.from_name(distribution_name).metadata return Distribution.from_name(distribution_name).metadata
@ -565,15 +622,11 @@ def entry_points():
:return: EntryPoint objects for all installed packages. :return: EntryPoint objects for all installed packages.
""" """
eps = itertools.chain.from_iterable( eps = itertools.chain.from_iterable(dist.entry_points for dist in distributions())
dist.entry_points for dist in distributions())
by_group = operator.attrgetter('group') by_group = operator.attrgetter('group')
ordered = sorted(eps, key=by_group) ordered = sorted(eps, key=by_group)
grouped = itertools.groupby(ordered, by_group) grouped = itertools.groupby(ordered, by_group)
return { return {group: tuple(eps) for group, eps in grouped}
group: tuple(eps)
for group, eps in grouped
}
def files(distribution_name): def files(distribution_name):

View File

@ -769,7 +769,7 @@ class uname_result(
): ):
""" """
A uname_result that's largely compatible with a A uname_result that's largely compatible with a
simple namedtuple except that 'platform' is simple namedtuple except that 'processor' is
resolved late and cached to avoid calling "uname" resolved late and cached to avoid calling "uname"
except when needed. except when needed.
""" """
@ -784,12 +784,25 @@ class uname_result(
(self.processor,) (self.processor,)
) )
@classmethod
def _make(cls, iterable):
# override factory to affect length check
num_fields = len(cls._fields)
result = cls.__new__(cls, *iterable)
if len(result) != num_fields + 1:
msg = f'Expected {num_fields} arguments, got {len(result)}'
raise TypeError(msg)
return result
def __getitem__(self, key): def __getitem__(self, key):
return tuple(iter(self))[key] return tuple(self)[key]
def __len__(self): def __len__(self):
return len(tuple(iter(self))) return len(tuple(iter(self)))
def __reduce__(self):
return uname_result, tuple(self)[:len(self._fields)]
_uname_cache = None _uname_cache = None

View File

@ -628,6 +628,39 @@ if hasattr(os, "fork"):
self.collect_children(blocking=self.block_on_close) self.collect_children(blocking=self.block_on_close)
class _Threads(list):
"""
Joinable list of all non-daemon threads.
"""
def append(self, thread):
self.reap()
if thread.daemon:
return
super().append(thread)
def pop_all(self):
self[:], result = [], self[:]
return result
def join(self):
for thread in self.pop_all():
thread.join()
def reap(self):
self[:] = (thread for thread in self if thread.is_alive())
class _NoThreads:
"""
Degenerate version of _Threads.
"""
def append(self, thread):
pass
def join(self):
pass
class ThreadingMixIn: class ThreadingMixIn:
"""Mix-in class to handle each request in a new thread.""" """Mix-in class to handle each request in a new thread."""
@ -636,9 +669,9 @@ class ThreadingMixIn:
daemon_threads = False daemon_threads = False
# If true, server_close() waits until all non-daemonic threads terminate. # If true, server_close() waits until all non-daemonic threads terminate.
block_on_close = True block_on_close = True
# For non-daemonic threads, list of threading.Threading objects # Threads object
# used by server_close() to wait for all threads completion. # used by server_close() to wait for all threads completion.
_threads = None _threads = _NoThreads()
def process_request_thread(self, request, client_address): def process_request_thread(self, request, client_address):
"""Same as in BaseServer but as a thread. """Same as in BaseServer but as a thread.
@ -655,23 +688,17 @@ class ThreadingMixIn:
def process_request(self, request, client_address): def process_request(self, request, client_address):
"""Start a new thread to process the request.""" """Start a new thread to process the request."""
if self.block_on_close:
vars(self).setdefault('_threads', _Threads())
t = threading.Thread(target = self.process_request_thread, t = threading.Thread(target = self.process_request_thread,
args = (request, client_address)) args = (request, client_address))
t.daemon = self.daemon_threads t.daemon = self.daemon_threads
if not t.daemon and self.block_on_close:
if self._threads is None:
self._threads = []
self._threads.append(t) self._threads.append(t)
t.start() t.start()
def server_close(self): def server_close(self):
super().server_close() super().server_close()
if self.block_on_close: self._threads.join()
threads = self._threads
self._threads = None
if threads:
for thread in threads:
thread.join()
if hasattr(os, "fork"): if hasattr(os, "fork"):

View File

@ -7,6 +7,7 @@ import textwrap
import contextlib import contextlib
from test.support.os_helper import FS_NONASCII from test.support.os_helper import FS_NONASCII
from typing import Dict, Union
@contextlib.contextmanager @contextlib.contextmanager
@ -71,8 +72,13 @@ class OnSysPath(Fixtures):
self.fixtures.enter_context(self.add_sys_path(self.site_dir)) self.fixtures.enter_context(self.add_sys_path(self.site_dir))
# Except for python/mypy#731, prefer to define
# FilesDef = Dict[str, Union['FilesDef', str]]
FilesDef = Dict[str, Union[Dict[str, Union[Dict[str, str], str]], str]]
class DistInfoPkg(OnSysPath, SiteDir): class DistInfoPkg(OnSysPath, SiteDir):
files = { files: FilesDef = {
"distinfo_pkg-1.0.0.dist-info": { "distinfo_pkg-1.0.0.dist-info": {
"METADATA": """ "METADATA": """
Name: distinfo-pkg Name: distinfo-pkg
@ -86,7 +92,7 @@ class DistInfoPkg(OnSysPath, SiteDir):
[entries] [entries]
main = mod:main main = mod:main
ns:sub = mod:main ns:sub = mod:main
""" """,
}, },
"mod.py": """ "mod.py": """
def main(): def main():
@ -99,6 +105,42 @@ class DistInfoPkg(OnSysPath, SiteDir):
build_files(DistInfoPkg.files, self.site_dir) build_files(DistInfoPkg.files, self.site_dir)
class DistInfoPkgWithDot(OnSysPath, SiteDir):
files: FilesDef = {
"pkg_dot-1.0.0.dist-info": {
"METADATA": """
Name: pkg.dot
Version: 1.0.0
""",
},
}
def setUp(self):
super(DistInfoPkgWithDot, self).setUp()
build_files(DistInfoPkgWithDot.files, self.site_dir)
class DistInfoPkgWithDotLegacy(OnSysPath, SiteDir):
files: FilesDef = {
"pkg.dot-1.0.0.dist-info": {
"METADATA": """
Name: pkg.dot
Version: 1.0.0
""",
},
"pkg.lot.egg-info": {
"METADATA": """
Name: pkg.lot
Version: 1.0.0
""",
},
}
def setUp(self):
super(DistInfoPkgWithDotLegacy, self).setUp()
build_files(DistInfoPkgWithDotLegacy.files, self.site_dir)
class DistInfoPkgOffPath(SiteDir): class DistInfoPkgOffPath(SiteDir):
def setUp(self): def setUp(self):
super(DistInfoPkgOffPath, self).setUp() super(DistInfoPkgOffPath, self).setUp()
@ -106,7 +148,7 @@ class DistInfoPkgOffPath(SiteDir):
class EggInfoPkg(OnSysPath, SiteDir): class EggInfoPkg(OnSysPath, SiteDir):
files = { files: FilesDef = {
"egginfo_pkg.egg-info": { "egginfo_pkg.egg-info": {
"PKG-INFO": """ "PKG-INFO": """
Name: egginfo-pkg Name: egginfo-pkg
@ -129,7 +171,7 @@ class EggInfoPkg(OnSysPath, SiteDir):
[test] [test]
pytest pytest
""", """,
"top_level.txt": "mod\n" "top_level.txt": "mod\n",
}, },
"mod.py": """ "mod.py": """
def main(): def main():
@ -143,7 +185,7 @@ class EggInfoPkg(OnSysPath, SiteDir):
class EggInfoFile(OnSysPath, SiteDir): class EggInfoFile(OnSysPath, SiteDir):
files = { files: FilesDef = {
"egginfo_file.egg-info": """ "egginfo_file.egg-info": """
Metadata-Version: 1.0 Metadata-Version: 1.0
Name: egginfo_file Name: egginfo_file
@ -164,7 +206,7 @@ class EggInfoFile(OnSysPath, SiteDir):
class LocalPackage: class LocalPackage:
files = { files: FilesDef = {
"setup.py": """ "setup.py": """
import setuptools import setuptools
setuptools.setup(name="local-pkg", version="2.0.1") setuptools.setup(name="local-pkg", version="2.0.1")
@ -214,8 +256,7 @@ def build_files(file_defs, prefix=pathlib.Path()):
class FileBuilder: class FileBuilder:
def unicode_filename(self): def unicode_filename(self):
return FS_NONASCII or \ return FS_NONASCII or self.skip("File system does not support non-ascii.")
self.skip("File system does not support non-ascii.")
def DALS(str): def DALS(str):

View File

@ -1,5 +1,3 @@
# coding: utf-8
import re import re
import json import json
import pickle import pickle
@ -14,9 +12,13 @@ except ImportError:
from . import fixtures from . import fixtures
from importlib.metadata import ( from importlib.metadata import (
Distribution, EntryPoint, Distribution,
PackageNotFoundError, distributions, EntryPoint,
entry_points, metadata, version, PackageNotFoundError,
distributions,
entry_points,
metadata,
version,
) )
@ -74,8 +76,7 @@ class ImportTests(fixtures.DistInfoPkg, unittest.TestCase):
assert ep.load() is importlib.metadata assert ep.load() is importlib.metadata
class NameNormalizationTests( class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
@staticmethod @staticmethod
def pkg_with_dashes(site_dir): def pkg_with_dashes(site_dir):
""" """
@ -144,11 +145,15 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
metadata_dir.mkdir() metadata_dir.mkdir()
metadata = metadata_dir / 'METADATA' metadata = metadata_dir / 'METADATA'
with metadata.open('w', encoding='utf-8') as fp: with metadata.open('w', encoding='utf-8') as fp:
fp.write(textwrap.dedent(""" fp.write(
textwrap.dedent(
"""
Name: portend Name: portend
pôrˈtend pôrˈtend
""").lstrip()) """
).lstrip()
)
return 'portend' return 'portend'
def test_metadata_loads(self): def test_metadata_loads(self):
@ -162,24 +167,12 @@ class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase):
assert meta.get_payload() == 'pôrˈtend\n' assert meta.get_payload() == 'pôrˈtend\n'
class DiscoveryTests(fixtures.EggInfoPkg, class DiscoveryTests(fixtures.EggInfoPkg, fixtures.DistInfoPkg, unittest.TestCase):
fixtures.DistInfoPkg,
unittest.TestCase):
def test_package_discovery(self): def test_package_discovery(self):
dists = list(distributions()) dists = list(distributions())
assert all( assert all(isinstance(dist, Distribution) for dist in dists)
isinstance(dist, Distribution) assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists)
for dist in dists assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists)
)
assert any(
dist.metadata['Name'] == 'egginfo-pkg'
for dist in dists
)
assert any(
dist.metadata['Name'] == 'distinfo-pkg'
for dist in dists
)
def test_invalid_usage(self): def test_invalid_usage(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@ -265,10 +258,21 @@ class TestEntryPoints(unittest.TestCase):
def test_attr(self): def test_attr(self):
assert self.ep.attr is None assert self.ep.attr is None
def test_sortable(self):
"""
EntryPoint objects are sortable, but result is undefined.
"""
sorted(
[
EntryPoint('b', 'val', 'group'),
EntryPoint('a', 'val', 'group'),
]
)
class FileSystem( class FileSystem(
fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase
unittest.TestCase): ):
def test_unicode_dir_on_sys_path(self): def test_unicode_dir_on_sys_path(self):
""" """
Ensure a Unicode subdirectory of a directory on sys.path Ensure a Unicode subdirectory of a directory on sys.path

View File

@ -2,20 +2,26 @@ import re
import textwrap import textwrap
import unittest import unittest
from collections.abc import Iterator
from . import fixtures from . import fixtures
from importlib.metadata import ( from importlib.metadata import (
Distribution, PackageNotFoundError, distribution, Distribution,
entry_points, files, metadata, requires, version, PackageNotFoundError,
distribution,
entry_points,
files,
metadata,
requires,
version,
) )
class APITests( class APITests(
fixtures.EggInfoPkg, fixtures.EggInfoPkg,
fixtures.DistInfoPkg, fixtures.DistInfoPkg,
fixtures.DistInfoPkgWithDot,
fixtures.EggInfoFile, fixtures.EggInfoFile,
unittest.TestCase): unittest.TestCase,
):
version_pattern = r'\d+\.\d+(\.\d)?' version_pattern = r'\d+\.\d+(\.\d)?'
@ -33,15 +39,27 @@ class APITests(
with self.assertRaises(PackageNotFoundError): with self.assertRaises(PackageNotFoundError):
distribution('does-not-exist') distribution('does-not-exist')
def test_name_normalization(self):
names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot'
for name in names:
with self.subTest(name):
assert distribution(name).metadata['Name'] == 'pkg.dot'
def test_prefix_not_matched(self):
prefixes = 'p', 'pkg', 'pkg.'
for prefix in prefixes:
with self.subTest(prefix):
with self.assertRaises(PackageNotFoundError):
distribution(prefix)
def test_for_top_level(self): def test_for_top_level(self):
self.assertEqual( self.assertEqual(
distribution('egginfo-pkg').read_text('top_level.txt').strip(), distribution('egginfo-pkg').read_text('top_level.txt').strip(), 'mod'
'mod') )
def test_read_text(self): def test_read_text(self):
top_level = [ top_level = [
path for path in files('egginfo-pkg') path for path in files('egginfo-pkg') if path.name == 'top_level.txt'
if path.name == 'top_level.txt'
][0] ][0]
self.assertEqual(top_level.read_text(), 'mod\n') self.assertEqual(top_level.read_text(), 'mod\n')
@ -51,6 +69,13 @@ class APITests(
self.assertEqual(ep.value, 'mod:main') self.assertEqual(ep.value, 'mod:main')
self.assertEqual(ep.extras, []) self.assertEqual(ep.extras, [])
def test_entry_points_distribution(self):
entries = dict(entry_points()['entries'])
for entry in ("main", "ns:sub"):
ep = entries[entry]
self.assertIn(ep.dist.name, ('distinfo-pkg', 'egginfo-pkg'))
self.assertEqual(ep.dist.version, "1.0.0")
def test_metadata_for_this_package(self): def test_metadata_for_this_package(self):
md = metadata('egginfo-pkg') md = metadata('egginfo-pkg')
assert md['author'] == 'Steven Ma' assert md['author'] == 'Steven Ma'
@ -75,13 +100,8 @@ class APITests(
def test_file_hash_repr(self): def test_file_hash_repr(self):
assertRegex = self.assertRegex assertRegex = self.assertRegex
util = [ util = [p for p in files('distinfo-pkg') if p.name == 'mod.py'][0]
p for p in files('distinfo-pkg') assertRegex(repr(util.hash), '<FileHash mode: sha256 value: .*>')
if p.name == 'mod.py'
][0]
assertRegex(
repr(util.hash),
'<FileHash mode: sha256 value: .*>')
def test_files_dist_info(self): def test_files_dist_info(self):
self._test_files(files('distinfo-pkg')) self._test_files(files('distinfo-pkg'))
@ -99,10 +119,7 @@ class APITests(
def test_requires_egg_info(self): def test_requires_egg_info(self):
deps = requires('egginfo-pkg') deps = requires('egginfo-pkg')
assert len(deps) == 2 assert len(deps) == 2
assert any( assert any(dep == 'wheel >= 1.0; python_version >= "2.7"' for dep in deps)
dep == 'wheel >= 1.0; python_version >= "2.7"'
for dep in deps
)
def test_requires_dist_info(self): def test_requires_dist_info(self):
deps = requires('distinfo-pkg') deps = requires('distinfo-pkg')
@ -112,7 +129,8 @@ class APITests(
assert "pytest; extra == 'test'" in deps assert "pytest; extra == 'test'" in deps
def test_more_complex_deps_requires_text(self): def test_more_complex_deps_requires_text(self):
requires = textwrap.dedent(""" requires = textwrap.dedent(
"""
dep1 dep1
dep2 dep2
@ -124,7 +142,8 @@ class APITests(
[extra2:python_version < "3"] [extra2:python_version < "3"]
dep5 dep5
""") """
)
deps = sorted(Distribution._deps_from_requires_text(requires)) deps = sorted(Distribution._deps_from_requires_text(requires))
expected = [ expected = [
'dep1', 'dep1',
@ -140,17 +159,27 @@ class APITests(
assert deps == expected assert deps == expected
class LegacyDots(fixtures.DistInfoPkgWithDotLegacy, unittest.TestCase):
def test_name_normalization(self):
names = 'pkg.dot', 'pkg_dot', 'pkg-dot', 'pkg..dot', 'Pkg.Dot'
for name in names:
with self.subTest(name):
assert distribution(name).metadata['Name'] == 'pkg.dot'
def test_name_normalization_versionless_egg_info(self):
names = 'pkg.lot', 'pkg_lot', 'pkg-lot', 'pkg..lot', 'Pkg.Lot'
for name in names:
with self.subTest(name):
assert distribution(name).metadata['Name'] == 'pkg.lot'
class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase): class OffSysPathTests(fixtures.DistInfoPkgOffPath, unittest.TestCase):
def test_find_distributions_specified_path(self): def test_find_distributions_specified_path(self):
dists = Distribution.discover(path=[str(self.site_dir)]) dists = Distribution.discover(path=[str(self.site_dir)])
assert any( assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists)
dist.metadata['Name'] == 'distinfo-pkg'
for dist in dists
)
def test_distribution_at_pathlib(self): def test_distribution_at_pathlib(self):
"""Demonstrate how to load metadata direct from a directory. """Demonstrate how to load metadata direct from a directory."""
"""
dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info' dist_info_path = self.site_dir / 'distinfo_pkg-1.0.0.dist-info'
dist = Distribution.at(dist_info_path) dist = Distribution.at(dist_info_path)
assert dist.version == '1.0.0' assert dist.version == '1.0.0'

View File

@ -3,8 +3,12 @@ import unittest
from contextlib import ExitStack from contextlib import ExitStack
from importlib.metadata import ( from importlib.metadata import (
distribution, entry_points, files, PackageNotFoundError, PackageNotFoundError,
version, distributions, distribution,
distributions,
entry_points,
files,
version,
) )
from importlib import resources from importlib import resources

View File

@ -1,4 +1,6 @@
import os import os
import copy
import pickle
import platform import platform
import subprocess import subprocess
import sys import sys
@ -234,6 +236,38 @@ class PlatformTest(unittest.TestCase):
) )
self.assertEqual(tuple(res), expected) self.assertEqual(tuple(res), expected)
def test_uname_replace(self):
res = platform.uname()
new = res._replace(
system='system', node='node', release='release',
version='version', machine='machine')
self.assertEqual(new.system, 'system')
self.assertEqual(new.node, 'node')
self.assertEqual(new.release, 'release')
self.assertEqual(new.version, 'version')
self.assertEqual(new.machine, 'machine')
# processor cannot be replaced
self.assertEqual(new.processor, res.processor)
def test_uname_copy(self):
uname = platform.uname()
self.assertEqual(copy.copy(uname), uname)
self.assertEqual(copy.deepcopy(uname), uname)
def test_uname_pickle(self):
orig = platform.uname()
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
with self.subTest(protocol=proto):
pickled = pickle.dumps(orig, proto)
restored = pickle.loads(pickled)
self.assertEqual(restored, orig)
def test_uname_slices(self):
res = platform.uname()
expected = tuple(res)
self.assertEqual(res[:], expected)
self.assertEqual(res[:5], expected[:5])
@unittest.skipIf(sys.platform in ['win32', 'OpenVMS'], "uname -p not used") @unittest.skipIf(sys.platform in ['win32', 'OpenVMS'], "uname -p not used")
def test_uname_processor(self): def test_uname_processor(self):
""" """

View File

@ -277,6 +277,13 @@ class SocketServerTest(unittest.TestCase):
t.join() t.join()
s.server_close() s.server_close()
def test_close_immediately(self):
class MyServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
pass
server = MyServer((HOST, 0), lambda: None)
server.server_close()
def test_tcpserver_bind_leak(self): def test_tcpserver_bind_leak(self):
# Issue #22435: the server socket wouldn't be closed if bind()/listen() # Issue #22435: the server socket wouldn't be closed if bind()/listen()
# failed. # failed.
@ -491,6 +498,22 @@ class MiscTestCase(unittest.TestCase):
self.assertEqual(server.shutdown_called, 1) self.assertEqual(server.shutdown_called, 1)
server.server_close() server.server_close()
def test_threads_reaped(self):
"""
In #37193, users reported a memory leak
due to the saving of every request thread. Ensure that
not all threads are kept forever.
"""
class MyServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
pass
server = MyServer((HOST, 0), socketserver.StreamRequestHandler)
for n in range(10):
with socket.create_connection(server.server_address):
server.handle_request()
self.assertLess(len(server._threads), 10)
server.server_close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,2 @@
Fixed memory leak in ``socketserver.ThreadingMixIn`` introduced in Python
3.7.

View File

@ -0,0 +1 @@
Restore compatibility for ``uname_result`` around deepcopy and _replace.

View File

@ -0,0 +1,6 @@
In ``importlib.metadata``: - ``EntryPoint`` objects now expose a ``.dist``
object referencing the ``Distribution`` when constructed from a
``Distribution``. - Add support for package discovery under package
normalization rules. - The object returned by ``metadata()`` now has a
formally-defined protocol called ``PackageMetadata`` with declared support
for the ``.get_all()`` method. - Synced with importlib_metadata 3.3.