diff --git a/.coveragerc b/.coveragerc index 1b8c50e9..1763e9e1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,8 @@ omit = */_itertools.py exercises.py */pip-run-* +disable_warnings = + couldnt-parse [report] show_missing = True diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3e20ae63..a9a0d2c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,14 +2,46 @@ name: tests on: [push, pull_request] +env: + # Environment variables to support color support (jaraco/skeleton#66): + # Request colored output from CLI tools supporting it. Different tools + # interpret the value differently. For some, just being set is sufficient. + # For others, it must be a non-zero integer. For yet others, being set + # to a non-empty value is sufficient. For tox, it must be one of + # , 0, 1, false, no, off, on, true, yes. The only enabling value + # in common is "1". + FORCE_COLOR: 1 + # MyPy's color enforcement (must be a non-zero number) + MYPY_FORCE_COLOR: -42 + # Recognized by the `py` package, dependency of `pytest` (must be "1") + PY_COLORS: 1 + # Make tox-wrapped tools see color requests + TOX_TESTENV_PASSENV: >- + FORCE_COLOR + MYPY_FORCE_COLOR + NO_COLOR + PY_COLORS + PYTEST_THEME + PYTEST_THEME_MODE + + # Suppress noisy pip warnings + PIP_DISABLE_PIP_VERSION_CHECK: 'true' + PIP_NO_PYTHON_VERSION_WARNING: 'true' + PIP_NO_WARN_SCRIPT_LOCATION: 'true' + + # Disable the spinner, noise in GHA; TODO(webknjaz): Fix this upstream + # Must be "1". + TOX_PARALLEL_NO_SPINNER: 1 + + jobs: test: strategy: matrix: python: - "3.7" - - "3.10" - "3.11" + - "3.12" # Workaround for actions/setup-python#508 dev: - -dev @@ -18,6 +50,12 @@ jobs: - macos-latest - windows-latest include: + - python: "3.8" + platform: ubuntu-latest + - python: "3.9" + platform: ubuntu-latest + - python: "3.10" + platform: ubuntu-latest - python: pypy3.9 platform: ubuntu-latest - platform: ubuntu-latest @@ -25,6 +63,7 @@ jobs: - platform: ubuntu-latest python: "3.9" runs-on: ${{ matrix.platform }} + continue-on-error: ${{ matrix.python == '3.12' }} steps: - uses: actions/checkout@v3 with: @@ -41,11 +80,28 @@ jobs: - name: Run tests run: tox + docs: + runs-on: ubuntu-latest + env: + TOXENV: docs + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }}${{ matrix.dev }} + - name: Install tox + run: | + python -m pip install tox + - name: Run tests + run: tox + check: # This job does nothing and is only used for the branch protection if: always() needs: - test + - docs runs-on: ubuntu-latest diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..6bef3493 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 +python: + install: + - path: . + extra_requirements: + - docs + +# workaround for readthedocs/readthedocs.org#9623 +build: + # workaround for readthedocs/readthedocs.org#9635 + os: ubuntu-22.04 + tools: + python: "3" diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index cc698548..00000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -python: - install: - - path: . - extra_requirements: - - docs diff --git a/CHANGES.rst b/CHANGES.rst index f48b9d20..eccdd5ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,47 @@ +v6.1.0 +====== + +* #428: ``packages_distributions`` now honors packages and modules + with Python modules that not ``.py`` sources (e.g. ``.pyc``, + ``.so``). + +v6.0.1 +====== + +* #434: Expand protocol for ``PackageMetadata.get_all`` to match + the upstream implementation of ``email.message.Message.get_all`` + in python/typeshed#9620. + +v6.0.0 +====== + +* #419: Declared ``Distribution`` as an abstract class, enforcing + definition of abstract methods in instantiated subclasses. It's no + longer possible to instantiate a ``Distribution`` or any subclasses + unless they define the abstract methods. + + Please comment in the issue if this change breaks any projects. + This change will likely be rolled back if it causes significant + disruption. + +v5.2.0 +====== + +* #371: Deprecated expectation that ``PackageMetadata.__getitem__`` + will return ``None`` for missing keys. In the future, it will raise a + ``KeyError``. + +v5.1.0 +====== + +* #415: Instrument ``SimplePath`` with generic support. + +v5.0.0 +====== + +* #97, #284, #300: Removed compatibility shims for deprecated entry + point interfaces. + v4.13.0 ======= diff --git a/README.rst b/README.rst index 47fa0cb2..dae339b9 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,7 @@ .. image:: https://img.shields.io/pypi/v/importlib_metadata.svg - :target: `PyPI link`_ + :target: https://pypi.org/project/importlib_metadata .. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg - :target: `PyPI link`_ - -.. _PyPI link: https://pypi.org/project/importlib_metadata .. image:: https://github.com/python/importlib_metadata/workflows/tests/badge.svg :target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22 @@ -17,7 +14,7 @@ .. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest :target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest -.. image:: https://img.shields.io/badge/skeleton-2022-informational +.. image:: https://img.shields.io/badge/skeleton-2023-informational :target: https://blog.jaraco.com/skeleton .. image:: https://tidelift.com/badges/package/pypi/importlib-metadata @@ -42,9 +39,11 @@ were contributed to different versions in the standard library: * - importlib_metadata - stdlib - * - 4.8 + * - 5.0 + - 3.12 + * - 4.13 - 3.11 - * - 4.4 + * - 4.6 - 3.10 * - 1.4 - 3.8 diff --git a/docs/conf.py b/docs/conf.py index 01f0d8d8..8e7762d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - extensions = [ 'sphinx.ext.autodoc', 'jaraco.packaging.sphinx', @@ -63,9 +61,11 @@ ), ) -# Workaround for #316 nitpick_ignore = [ + # Workaround for #316 ('py:class', 'importlib_metadata.EntryPoints'), ('py:class', 'importlib_metadata.SelectableGroups'), ('py:class', 'importlib_metadata._meta._T'), + # Workaround for #435 + ('py:class', '_T'), ] diff --git a/docs/using.rst b/docs/using.rst index 40616d86..831ad62b 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -7,7 +7,7 @@ ``importlib_metadata`` is a library that provides access to the metadata of an installed :term:`packaging:Distribution Package`, such as its entry points -or its top-level names (:term:`packaging:Import Package`s, modules, if any). +or its top-level names (:term:`packaging:Import Package`\s, modules, if any). Built in part on Python's import system, this library intends to replace similar functionality in the `entry point API`_ and `metadata API`_ of ``pkg_resources``. Along with @@ -164,11 +164,10 @@ for more information on entry points, their definition, and usage. The "selectable" entry points were introduced in ``importlib_metadata`` 3.6 and Python 3.10. Prior to those changes, ``entry_points`` accepted no parameters and always returned a dictionary of entry points, keyed -by group. For compatibility, if no parameters are passed to entry_points, -a ``SelectableGroups`` object is returned, implementing that dict -interface. In the future, calling ``entry_points`` with no parameters -will return an ``EntryPoints`` object. Users should rely on the selection -interface to retrieve entry points by group. +by group. With ``importlib_metadata`` 5.0 and Python 3.12, +``entry_points`` always returns an ``EntryPoints`` object. See +`backports.entry_points_selectable `_ +for compatibility options. .. _metadata: @@ -200,7 +199,7 @@ all the metadata in a JSON-compatible form per PEP 566:: 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 `. + `PackageMetadata protocol `_. .. _version: @@ -290,6 +289,10 @@ Python module or :term:`packaging:Import Package`:: >>> packages_distributions() {'importlib_metadata': ['importlib-metadata'], 'yaml': ['PyYAML'], 'jaraco': ['jaraco.classes', 'jaraco.functools'], ...} +Some editable installs, `do not supply top-level names +`_, and thus this +function is not reliable with such installs. + .. _distributions: Distributions diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index eab5d4c1..8d9f0016 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -13,6 +13,7 @@ import itertools import posixpath import collections +import inspect from . import _adapters, _meta, _py39compat from ._collections import FreezableDefaultDict, Pair @@ -29,7 +30,7 @@ from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Optional __all__ = [ @@ -139,6 +140,7 @@ class DeprecatedTuple: 1 """ + # Do not remove prior to 2023-05-01 or Python 3.13 _warn = functools.partial( warnings.warn, "EntryPoint tuple interface is deprecated. Access members by name.", @@ -227,17 +229,6 @@ def _for(self, dist): vars(self).update(dist=dist) return self - def __iter__(self): - """ - Supply iter so one may construct dicts of EntryPoints by name. - """ - msg = ( - "Construction of dict of EntryPoints is deprecated in " - "favor of EntryPoints." - ) - warnings.warn(msg, DeprecationWarning) - return iter((self.name, self)) - def matches(self, **params): """ EntryPoint matches the given parameters. @@ -283,77 +274,7 @@ def __hash__(self): return hash(self._key()) -class DeprecatedList(list): - """ - Allow an otherwise immutable object to implement mutability - for compatibility. - - >>> recwarn = getfixture('recwarn') - >>> dl = DeprecatedList(range(3)) - >>> dl[0] = 1 - >>> dl.append(3) - >>> del dl[3] - >>> dl.reverse() - >>> dl.sort() - >>> dl.extend([4]) - >>> dl.pop(-1) - 4 - >>> dl.remove(1) - >>> dl += [5] - >>> dl + [6] - [1, 2, 5, 6] - >>> dl + (6,) - [1, 2, 5, 6] - >>> dl.insert(0, 0) - >>> dl - [0, 1, 2, 5] - >>> dl == [0, 1, 2, 5] - True - >>> dl == (0, 1, 2, 5) - True - >>> len(recwarn) - 1 - """ - - __slots__ = () - - _warn = functools.partial( - warnings.warn, - "EntryPoints list interface is deprecated. Cast to list if needed.", - DeprecationWarning, - stacklevel=pypy_partial(2), - ) - - def _wrap_deprecated_method(method_name: str): # type: ignore - def wrapped(self, *args, **kwargs): - self._warn() - return getattr(super(), method_name)(*args, **kwargs) - - return method_name, wrapped - - locals().update( - map( - _wrap_deprecated_method, - '__setitem__ __delitem__ append reverse extend pop remove ' - '__iadd__ insert sort'.split(), - ) - ) - - def __add__(self, other): - if not isinstance(other, tuple): - self._warn() - other = tuple(other) - return self.__class__(tuple(self) + other) - - def __eq__(self, other): - if not isinstance(other, tuple): - self._warn() - other = tuple(other) - - return tuple(self).__eq__(other) - - -class EntryPoints(DeprecatedList): +class EntryPoints(tuple): """ An immutable collection of selectable EntryPoint objects. """ @@ -364,14 +285,6 @@ def __getitem__(self, name): # -> EntryPoint: """ Get the EntryPoint in self matching name. """ - if isinstance(name, int): - warnings.warn( - "Accessing entry points by index is deprecated. " - "Cast to tuple if needed.", - DeprecationWarning, - stacklevel=2, - ) - return super().__getitem__(name) try: return next(iter(self.select(name=name))) except StopIteration: @@ -382,8 +295,7 @@ def select(self, **params): Select entry points from self that match the given parameters (typically group and/or name). """ - candidates = (_py39compat.ep_matches(ep, **params) for ep in self) - return EntryPoints(ep for ep, predicate in candidates if predicate) + return EntryPoints(ep for ep in self if _py39compat.ep_matches(ep, **params)) @property def names(self): @@ -396,10 +308,6 @@ def names(self): def groups(self): """ Return the set of all groups of all entry points. - - For coverage while SelectableGroups is present. - >>> EntryPoints().groups - set() """ return {ep.group for ep in self} @@ -415,101 +323,6 @@ def _from_text(text): ) -class Deprecated: - """ - Compatibility add-in for mapping to indicate that - mapping behavior is deprecated. - - >>> recwarn = getfixture('recwarn') - >>> class DeprecatedDict(Deprecated, dict): pass - >>> dd = DeprecatedDict(foo='bar') - >>> dd.get('baz', None) - >>> dd['foo'] - 'bar' - >>> list(dd) - ['foo'] - >>> list(dd.keys()) - ['foo'] - >>> 'foo' in dd - True - >>> list(dd.values()) - ['bar'] - >>> len(recwarn) - 1 - """ - - _warn = functools.partial( - warnings.warn, - "SelectableGroups dict interface is deprecated. Use select.", - DeprecationWarning, - stacklevel=pypy_partial(2), - ) - - def __getitem__(self, name): - self._warn() - return super().__getitem__(name) - - def get(self, name, default=None): - self._warn() - return super().get(name, default) - - def __iter__(self): - self._warn() - return super().__iter__() - - def __contains__(self, *args): - self._warn() - return super().__contains__(*args) - - def keys(self): - self._warn() - return super().keys() - - def values(self): - self._warn() - return super().values() - - -class SelectableGroups(Deprecated, dict): - """ - A backward- and forward-compatible result from - entry_points that fully implements the dict interface. - """ - - @classmethod - def load(cls, eps): - by_group = operator.attrgetter('group') - ordered = sorted(eps, key=by_group) - grouped = itertools.groupby(ordered, by_group) - return cls((group, EntryPoints(eps)) for group, eps in grouped) - - @property - def _all(self): - """ - Reconstruct a list of all entrypoints from the groups. - """ - groups = super(Deprecated, self).values() - return EntryPoints(itertools.chain.from_iterable(groups)) - - @property - def groups(self): - return self._all.groups - - @property - def names(self): - """ - for coverage: - >>> SelectableGroups().names - set() - """ - return self._all.names - - def select(self, **params): - if not params: - return self - return self._all.select(**params) - - class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" @@ -534,7 +347,7 @@ def __repr__(self): return f'' -class Distribution: +class Distribution(metaclass=abc.ABCMeta): """A Python distribution package.""" @abc.abstractmethod @@ -1029,27 +842,19 @@ def version(distribution_name): """ -def entry_points(**params) -> Union[EntryPoints, SelectableGroups]: +def entry_points(**params) -> EntryPoints: """Return EntryPoint objects for all installed packages. Pass selection parameters (group or name) to filter the result to entry points matching those properties (see EntryPoints.select()). - For compatibility, returns ``SelectableGroups`` object unless - selection parameters are supplied. In the future, this function - will return ``EntryPoints`` instead of ``SelectableGroups`` - even when no selection parameters are supplied. - - For maximum future compatibility, pass selection parameters - or invoke ``.select`` with parameters on the result. - - :return: EntryPoints or SelectableGroups for all installed packages. + :return: EntryPoints for all installed packages. """ eps = itertools.chain.from_iterable( dist.entry_points for dist in _unique(distributions()) ) - return SelectableGroups.load(eps).select(**params) + return EntryPoints(eps).select(**params) def files(distribution_name): @@ -1093,8 +898,8 @@ def _top_level_declared(dist): def _top_level_inferred(dist): - return { - f.parts[0] if len(f.parts) > 1 else f.with_suffix('').name + opt_names = { + f.parts[0] if len(f.parts) > 1 else inspect.getmodulename(f) for f in always_iterable(dist.files) - if f.suffix == ".py" } + return filter(None, opt_names) diff --git a/importlib_metadata/_adapters.py b/importlib_metadata/_adapters.py index aa460d3e..e33cba5e 100644 --- a/importlib_metadata/_adapters.py +++ b/importlib_metadata/_adapters.py @@ -1,8 +1,20 @@ +import functools +import warnings import re import textwrap import email.message from ._text import FoldedCase +from ._compat import pypy_partial + + +# Do not remove prior to 2024-01-01 or Python 3.14 +_warn = functools.partial( + warnings.warn, + "Implicit None on return values is deprecated and will raise KeyErrors.", + DeprecationWarning, + stacklevel=pypy_partial(2), +) class Message(email.message.Message): @@ -39,6 +51,16 @@ def __init__(self, *args, **kwargs): def __iter__(self): return super().__iter__() + def __getitem__(self, item): + """ + Warn users that a ``KeyError`` can be expected when a + mising key is supplied. Ref python/importlib_metadata#371. + """ + res = super().__getitem__(item) + if res is None: + _warn() + return res + def _repair_headers(self): def redent(value): "Correct for RFC822 indentation" diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 37ee43e6..8621c680 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,5 +1,5 @@ from ._compat import Protocol -from typing import Any, Dict, Iterator, List, TypeVar, Union +from typing import Any, Dict, Iterator, List, Optional, TypeVar, Union, overload _T = TypeVar("_T") @@ -18,7 +18,13 @@ def __getitem__(self, key: str) -> str: def __iter__(self) -> Iterator[str]: ... # pragma: no cover - def get_all(self, name: str, failobj: _T = ...) -> Union[List[Any], _T]: + # overload per python/importlib_metadata#435 + @overload + def get_all(self, name: str, failobj: None = None) -> Optional[List[Any]]: + ... # pragma: no cover + + @overload + def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]: """ Return all values associated with a possibly multi-valued key. """ @@ -30,18 +36,19 @@ def json(self) -> Dict[str, Union[str, List[str]]]: """ -class SimplePath(Protocol): +class SimplePath(Protocol[_T]): """ A minimal subset of pathlib.Path required by PathDistribution. """ - def joinpath(self) -> 'SimplePath': + def joinpath(self) -> _T: ... # pragma: no cover - def __truediv__(self) -> 'SimplePath': + def __truediv__(self, other: Union[str, _T]) -> _T: ... # pragma: no cover - def parent(self) -> 'SimplePath': + @property + def parent(self) -> _T: ... # pragma: no cover def read_text(self) -> str: diff --git a/importlib_metadata/_py39compat.py b/importlib_metadata/_py39compat.py index cf9cc124..cde4558f 100644 --- a/importlib_metadata/_py39compat.py +++ b/importlib_metadata/_py39compat.py @@ -1,7 +1,7 @@ """ Compatibility layer with Python 3.8/3.9 """ -from typing import TYPE_CHECKING, Any, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: # pragma: no cover # Prevent circular imports on runtime. @@ -22,27 +22,14 @@ def normalized_name(dist: Distribution) -> Optional[str]: return Prepared.normalize(getattr(dist, "name", None) or dist.metadata['Name']) -def ep_matches(ep: EntryPoint, **params) -> Tuple[EntryPoint, bool]: +def ep_matches(ep: EntryPoint, **params) -> bool: """ Workaround for ``EntryPoint`` objects without the ``matches`` method. - For the sake of convenience, a tuple is returned containing not only the - boolean value corresponding to the predicate evalutation, but also a compatible - ``EntryPoint`` object that can be safely used at a later stage. - - For example, the following sequences of expressions should be compatible: - - # Sequence 1: using the compatibility layer - candidates = (_py39compat.ep_matches(ep, **params) for ep in entry_points) - [ep for ep, predicate in candidates if predicate] - - # Sequence 2: using Python 3.9+ - [ep for ep in entry_points if ep.matches(**params)] """ try: - return ep, ep.matches(**params) + return ep.matches(**params) except AttributeError: from . import EntryPoint # -> delay to prevent circular imports. # Reconstruct the EntryPoint object to make sure it is compatible. - _ep = EntryPoint(ep.name, ep.value, ep.group) - return _ep, _ep.matches(**params) + return EntryPoint(ep.name, ep.value, ep.group).matches(**params) diff --git a/mypy.ini b/mypy.ini index 976ba029..b6f97276 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,5 @@ [mypy] ignore_missing_imports = True +# required to support namespace packages +# https://github.com/python/mypy/issues/14057 +explicit_package_bases = True diff --git a/pyproject.toml b/pyproject.toml index 60de2424..27b0f18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ addopts = "--black" addopts = "--mypy" [tool.pytest-enabler.flake8] -addopts = "--flake8" +# disabled due to PyCQA/flake8#1438 +# addopts = "--flake8" [tool.pytest-enabler.cov] addopts = "--cov" diff --git a/pytest.ini b/pytest.ini index 80e98cc9..99a25199 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,12 @@ [pytest] norecursedirs=dist build .tox .eggs addopts=--doctest-modules -doctest_optionflags=ALLOW_UNICODE ELLIPSIS filterwarnings= + ## upstream + + # Ensure ResourceWarnings are emitted + default::ResourceWarning + # Suppress deprecation warning in flake8 ignore:SelectableGroups dict interface is deprecated::flake8 @@ -15,3 +19,14 @@ filterwarnings= ignore: is not using a cooperative constructor:pytest.PytestDeprecationWarning ignore:The \(fspath. py.path.local\) argument to Flake8Item is deprecated.:pytest.PytestDeprecationWarning ignore:Flake8Item is an Item subclass and should not be a collector:pytest.PytestWarning + + # shopkeep/pytest-black#67 + ignore:'encoding' argument not specified::pytest_black + + # realpython/pytest-mypy#152 + ignore:'encoding' argument not specified::pytest_mypy + + # python/cpython#100750 + ignore:'encoding' argument not specified::platform + + ## end upstream diff --git a/setup.cfg b/setup.cfg index 52b83dfd..f5c7280f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,7 +33,9 @@ testing = # upstream pytest >= 6 pytest-checkdocs >= 2.4 - pytest-flake8 + pytest-flake8; \ + # workaround for tholo/pytest-flake8#87 + python_version < "3.12" # workaround for tholo/pytest-flake8#87 flake8 < 5 pytest-black >= 0.3.7; \ @@ -58,6 +60,9 @@ docs = jaraco.packaging >= 9 rst.linker >= 1.9 furo + sphinx-lint + + # tidelift jaraco.tidelift >= 1.4 # local diff --git a/tests/test_api.py b/tests/test_api.py index 3f75cebb..2932c2d2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -32,7 +32,6 @@ class APITests( fixtures.EggInfoFile, unittest.TestCase, ): - version_pattern = r'\d+\.\d+(\.\d)?' def test_retrieves_version_of_self(self): @@ -124,62 +123,6 @@ def test_entry_points_missing_name(self): def test_entry_points_missing_group(self): assert entry_points(group='missing') == () - def test_entry_points_dict_construction(self): - """ - Prior versions of entry_points() returned simple lists and - allowed casting those lists into maps by name using ``dict()``. - Capture this now deprecated use-case. - """ - with suppress_known_deprecation() as caught: - eps = dict(entry_points(group='entries')) - - assert 'main' in eps - assert eps['main'] == entry_points(group='entries')['main'] - - # check warning - expected = next(iter(caught)) - assert expected.category is DeprecationWarning - assert "Construction of dict of EntryPoints is deprecated" in str(expected) - - def test_entry_points_by_index(self): - """ - Prior versions of Distribution.entry_points would return a - tuple that allowed access by index. - Capture this now deprecated use-case - See python/importlib_metadata#300 and bpo-44246. - """ - eps = distribution('distinfo-pkg').entry_points - with suppress_known_deprecation() as caught: - eps[0] - - # check warning - expected = next(iter(caught)) - assert expected.category is DeprecationWarning - assert "Accessing entry points by index is deprecated" in str(expected) - - def test_entry_points_groups_getitem(self): - """ - Prior versions of entry_points() returned a dict. Ensure - that callers using '.__getitem__()' are supported but warned to - migrate. - """ - with suppress_known_deprecation(): - entry_points()['entries'] == entry_points(group='entries') - - with self.assertRaises(KeyError): - entry_points()['missing'] - - def test_entry_points_groups_get(self): - """ - Prior versions of entry_points() returned a dict. Ensure - that callers using '.get()' are supported but warned to - migrate. - """ - with suppress_known_deprecation(): - entry_points().get('missing', 'default') == 'default' - entry_points().get('entries', 'default') == entry_points()['entries'] - entry_points().get('missing', ()) == () - def test_entry_points_allows_no_attributes(self): ep = entry_points().select(group='entries', name='main') with self.assertRaises(AttributeError): @@ -197,6 +140,14 @@ def test_importlib_metadata_version(self): resolved = version('importlib-metadata') assert re.match(self.version_pattern, resolved) + def test_missing_key_legacy(self): + """ + Requesting a missing key will still return None, but warn. + """ + md = metadata('distinfo-pkg') + with suppress_known_deprecation(): + assert md['does-not-exist'] is None + @staticmethod def _test_files(files): root = files[0].root diff --git a/tests/test_main.py b/tests/test_main.py index 921f5d9c..16367793 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,6 @@ import re -import json import pickle import unittest -import warnings import importlib import importlib_metadata import pyfakefs.fake_filesystem_unittest as ffs @@ -11,7 +9,6 @@ from importlib_metadata import ( Distribution, EntryPoint, - MetadataPathFinder, PackageNotFoundError, _unique, distributions, @@ -37,7 +34,7 @@ def test_for_name_does_not_exist(self): def test_package_not_found_mentions_metadata(self): """ When a package is not found, that could indicate that the - packgae is not installed or that it is installed without + package is not installed or that it is installed without metadata. Ensure the exception mentions metadata to help guide users toward the cause. See #124. """ @@ -46,9 +43,9 @@ def test_package_not_found_mentions_metadata(self): assert "metadata" in str(ctx.exception) - def test_new_style_classes(self): - self.assertIsInstance(Distribution, type) - self.assertIsInstance(MetadataPathFinder, type) + def test_abc_enforced(self): + with self.assertRaises(TypeError): + type('DistributionSubclass', (Distribution,), {})() @fixtures.parameterize( dict(name=None), @@ -259,14 +256,6 @@ def test_hashable(self): """EntryPoints should be hashable""" hash(self.ep) - def test_json_dump(self): - """ - json should not expect to be able to dump an EntryPoint - """ - with self.assertRaises(Exception): - with warnings.catch_warnings(record=True): - json.dumps(self.ep) - def test_module(self): assert self.ep.module == 'value' @@ -333,3 +322,34 @@ def test_packages_distributions_neither_toplevel_nor_files(self): prefix=self.site_dir, ) packages_distributions() + + def test_packages_distributions_all_module_types(self): + """ + Test top-level modules detected on a package without 'top-level.txt'. + """ + suffixes = importlib.machinery.all_suffixes() + fixtures.build_files( + { + 'all_distributions-1.0.0.dist-info': { + 'METADATA': """ + Name: all_distributions + Version: 1.0.0 + """, + 'RECORD': ''.join( + f'{i}-top-level{suffix},,\n' + f'{i}-in-namespace/mod{suffix},,\n' + f'{i}-in-package/__init__.py,,\n' + f'{i}-in-package/mod{suffix},,\n' + for i, suffix in enumerate(suffixes) + ), + }, + }, + prefix=self.site_dir, + ) + + distributions = packages_distributions() + + for i in range(len(suffixes)): + assert distributions[f'{i}-top-level'] == ['all_distributions'] + assert distributions[f'{i}-in-namespace'] == ['all_distributions'] + assert distributions[f'{i}-in-package'] == ['all_distributions'] diff --git a/tox.ini b/tox.ini index a0ce7c61..4d7d90d3 100644 --- a/tox.ini +++ b/tox.ini @@ -8,12 +8,15 @@ toxworkdir={env:TOX_WORK_DIR:.tox} [testenv] deps = +setenv = + PYTHONWARNDEFAULTENCODING = 1 commands = pytest {posargs} passenv = HOME usedevelop = True -extras = testing +extras = + testing [testenv:docs] @@ -23,6 +26,7 @@ extras = changedir = docs commands = python -m sphinx -W --keep-going . {toxinidir}/build/html + python -m sphinxlint [testenv:diffcov] deps =