diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 89ff3396..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "daily" - allow: - - dependency-type: "all" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c01fc4d..5841cc37 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,6 +56,13 @@ jobs: continue-on-error: ${{ matrix.python == '3.14' }} steps: - uses: actions/checkout@v4 + - name: Install build dependencies + # Install dependencies for building packages on pre-release Pythons + # jaraco/skeleton#161 + if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' + run: | + sudo apt update + sudo apt install -y libxml2-dev libxslt-dev - name: Setup Python uses: actions/setup-python@v4 with: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index dc8516ac..72437063 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,9 @@ python: extra_requirements: - doc +sphinx: + configuration: docs/conf.py + # required boilerplate readthedocs/readthedocs.org#10401 build: os: ubuntu-lts-latest diff --git a/NEWS.rst b/NEWS.rst index e5a4b397..4d0c4bdc 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,18 @@ +v8.7.0 +====== + +Features +-------- + +- ``.metadata()`` (and ``Distribution.metadata``) can now return ``None`` if the metadata directory exists but not metadata file is present. (#493) + + +Bugfixes +-------- + +- Raise consistent ValueError for invalid EntryPoint.value (#518) + + v8.6.1 ====== diff --git a/README.rst b/README.rst index ffb63387..a9e634f2 100644 --- a/README.rst +++ b/README.rst @@ -14,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-2024-informational +.. image:: https://img.shields.io/badge/skeleton-2025-informational :target: https://blog.jaraco.com/skeleton .. image:: https://tidelift.com/badges/package/pypi/importlib-metadata diff --git a/importlib_metadata/__init__.py b/importlib_metadata/__init__.py index 46a14e64..cdfc1f62 100644 --- a/importlib_metadata/__init__.py +++ b/importlib_metadata/__init__.py @@ -22,11 +22,12 @@ import sys import textwrap import types +from collections.abc import Iterable, Mapping from contextlib import suppress from importlib import import_module from importlib.abc import MetaPathFinder from itertools import starmap -from typing import Any, Iterable, List, Mapping, Match, Optional, Set, cast +from typing import Any from . import _meta from ._collections import FreezableDefaultDict, Pair @@ -37,6 +38,7 @@ from ._functools import method_cache, pass_none from ._itertools import always_iterable, bucket, unique_everseen from ._meta import PackageMetadata, SimplePath +from ._typing import md_none from .compat import py39, py311 __all__ = [ @@ -133,6 +135,12 @@ def valid(line: str): return line and not line.startswith('#') +class _EntryPointMatch(types.SimpleNamespace): + module: str + attr: str + extras: str + + class EntryPoint: """An entry point as defined by Python packaging conventions. @@ -148,6 +156,30 @@ class EntryPoint: 'attr' >>> ep.extras ['extra1', 'extra2'] + + If the value package or module are not valid identifiers, a + ValueError is raised on access. + + >>> EntryPoint(name=None, group=None, value='invalid-name').module + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + >>> EntryPoint(name=None, group=None, value='invalid-name').attr + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + >>> EntryPoint(name=None, group=None, value='invalid-name').extras + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + + The same thing happens on construction. + + >>> EntryPoint(name=None, group=None, value='invalid-name') + Traceback (most recent call last): + ... + ValueError: ('Invalid object reference...invalid-name... + """ pattern = re.compile( @@ -175,38 +207,44 @@ class EntryPoint: value: str group: str - dist: Optional[Distribution] = None + dist: Distribution | None = None def __init__(self, name: str, value: str, group: str) -> None: vars(self).update(name=name, value=value, group=group) + self.module def load(self) -> Any: """Load the entry point from its definition. If only a module is indicated by the value, return that module. Otherwise, return the named object. """ - match = cast(Match, self.pattern.match(self.value)) - module = import_module(match.group('module')) - attrs = filter(None, (match.group('attr') or '').split('.')) + module = import_module(self.module) + attrs = filter(None, (self.attr or '').split('.')) return functools.reduce(getattr, attrs, module) @property def module(self) -> str: - match = self.pattern.match(self.value) - assert match is not None - return match.group('module') + return self._match.module @property def attr(self) -> str: - match = self.pattern.match(self.value) - assert match is not None - return match.group('attr') + return self._match.attr @property - def extras(self) -> List[str]: + def extras(self) -> list[str]: + return re.findall(r'\w+', self._match.extras or '') + + @functools.cached_property + def _match(self) -> _EntryPointMatch: match = self.pattern.match(self.value) - assert match is not None - return re.findall(r'\w+', match.group('extras') or '') + if not match: + raise ValueError( + 'Invalid object reference. ' + 'See https://packaging.python.org' + '/en/latest/specifications/entry-points/#data-model', + self.value, + ) + return _EntryPointMatch(**match.groupdict()) def _for(self, dist): vars(self).update(dist=dist) @@ -305,14 +343,14 @@ def select(self, **params) -> EntryPoints: return EntryPoints(ep for ep in self if py39.ep_matches(ep, **params)) @property - def names(self) -> Set[str]: + def names(self) -> set[str]: """ Return the set of all names of all entry points. """ return {ep.name for ep in self} @property - def groups(self) -> Set[str]: + def groups(self) -> set[str]: """ Return the set of all groups of all entry points. """ @@ -333,7 +371,7 @@ def _from_text(text): class PackagePath(pathlib.PurePosixPath): """A reference to a path in a package""" - hash: Optional[FileHash] + hash: FileHash | None size: int dist: Distribution @@ -368,7 +406,7 @@ class Distribution(metaclass=abc.ABCMeta): """ @abc.abstractmethod - def read_text(self, filename) -> Optional[str]: + def read_text(self, filename) -> str | None: """Attempt to load metadata file given by the name. Python distribution metadata is organized by blobs of text @@ -428,7 +466,7 @@ def from_name(cls, name: str) -> Distribution: @classmethod def discover( - cls, *, context: Optional[DistributionFinder.Context] = None, **kwargs + cls, *, context: DistributionFinder.Context | None = None, **kwargs ) -> Iterable[Distribution]: """Return an iterable of Distribution objects for all packages. @@ -474,7 +512,7 @@ def _discover_resolvers(): return filter(None, declared) @property - def metadata(self) -> _meta.PackageMetadata: + def metadata(self) -> _meta.PackageMetadata | None: """Return the parsed metadata for this Distribution. The returned object will have keys that name the various bits of @@ -484,10 +522,8 @@ def metadata(self) -> _meta.PackageMetadata: Custom providers may provide the METADATA file or override this property. """ - # deferred for performance (python/cpython#109829) - from . import _adapters - opt_text = ( + text = ( self.read_text('METADATA') or self.read_text('PKG-INFO') # This last clause is here to support old egg-info files. Its @@ -495,13 +531,20 @@ def metadata(self) -> _meta.PackageMetadata: # (which points to the egg-info file) attribute unchanged. or self.read_text('') ) - text = cast(str, opt_text) + return self._assemble_message(text) + + @staticmethod + @pass_none + def _assemble_message(text: str) -> _meta.PackageMetadata: + # deferred for performance (python/cpython#109829) + from . import _adapters + return _adapters.Message(email.message_from_string(text)) @property def name(self) -> str: """Return the 'Name' metadata for the distribution package.""" - return self.metadata['Name'] + return md_none(self.metadata)['Name'] @property def _normalized_name(self): @@ -511,7 +554,7 @@ def _normalized_name(self): @property def version(self) -> str: """Return the 'Version' metadata for the distribution package.""" - return self.metadata['Version'] + return md_none(self.metadata)['Version'] @property def entry_points(self) -> EntryPoints: @@ -524,7 +567,7 @@ def entry_points(self) -> EntryPoints: return EntryPoints._from_text_for(self.read_text('entry_points.txt'), self) @property - def files(self) -> Optional[List[PackagePath]]: + def files(self) -> list[PackagePath] | None: """Files in this distribution. :return: List of PackagePath for this distribution or None @@ -616,7 +659,7 @@ def _read_files_egginfo_sources(self): return text and map('"{}"'.format, text.splitlines()) @property - def requires(self) -> Optional[List[str]]: + def requires(self) -> list[str] | None: """Generated requirements specified for this Distribution""" reqs = self._read_dist_info_reqs() or self._read_egg_info_reqs() return reqs and list(reqs) @@ -722,7 +765,7 @@ def __init__(self, **kwargs): vars(self).update(kwargs) @property - def path(self) -> List[str]: + def path(self) -> list[str]: """ The sequence of directory path that a distribution finder should search. @@ -874,7 +917,7 @@ class Prepared: normalized = None legacy_normalized = None - def __init__(self, name: Optional[str]): + def __init__(self, name: str | None): self.name = name if name is None: return @@ -944,7 +987,7 @@ def __init__(self, path: SimplePath) -> None: """ self._path = path - def read_text(self, filename: str | os.PathLike[str]) -> Optional[str]: + def read_text(self, filename: str | os.PathLike[str]) -> str | None: with suppress( FileNotFoundError, IsADirectoryError, @@ -1008,7 +1051,7 @@ def distributions(**kwargs) -> Iterable[Distribution]: return Distribution.discover(**kwargs) -def metadata(distribution_name: str) -> _meta.PackageMetadata: +def metadata(distribution_name: str) -> _meta.PackageMetadata | None: """Get the metadata for the named package. :param distribution_name: The name of the distribution package to query. @@ -1051,7 +1094,7 @@ def entry_points(**params) -> EntryPoints: return EntryPoints(eps).select(**params) -def files(distribution_name: str) -> Optional[List[PackagePath]]: +def files(distribution_name: str) -> list[PackagePath] | None: """Return a list of files for the named package. :param distribution_name: The name of the distribution package to query. @@ -1060,7 +1103,7 @@ def files(distribution_name: str) -> Optional[List[PackagePath]]: return distribution(distribution_name).files -def requires(distribution_name: str) -> Optional[List[str]]: +def requires(distribution_name: str) -> list[str] | None: """ Return a list of requirements for the named package. @@ -1070,7 +1113,7 @@ def requires(distribution_name: str) -> Optional[List[str]]: return distribution(distribution_name).requires -def packages_distributions() -> Mapping[str, List[str]]: +def packages_distributions() -> Mapping[str, list[str]]: """ Return a mapping of top-level packages to their distributions. @@ -1083,7 +1126,7 @@ def packages_distributions() -> Mapping[str, List[str]]: pkg_to_dist = collections.defaultdict(list) for dist in distributions(): for pkg in _top_level_declared(dist) or _top_level_inferred(dist): - pkg_to_dist[pkg].append(dist.metadata['Name']) + pkg_to_dist[pkg].append(md_none(dist.metadata)['Name']) return dict(pkg_to_dist) @@ -1091,7 +1134,7 @@ def _top_level_declared(dist): return (dist.read_text('top_level.txt') or '').split() -def _topmost(name: PackagePath) -> Optional[str]: +def _topmost(name: PackagePath) -> str | None: """ Return the top-most parent as long as there is a parent. """ diff --git a/importlib_metadata/_collections.py b/importlib_metadata/_collections.py index cf0954e1..fc5045d3 100644 --- a/importlib_metadata/_collections.py +++ b/importlib_metadata/_collections.py @@ -1,4 +1,5 @@ import collections +import typing # from jaraco.collections 3.3 @@ -24,7 +25,10 @@ def freeze(self): self._frozen = lambda key: self.default_factory() -class Pair(collections.namedtuple('Pair', 'name value')): +class Pair(typing.NamedTuple): + name: str + value: str + @classmethod def parse(cls, text): return cls(*map(str.strip, text.split("=", 1))) diff --git a/importlib_metadata/_meta.py b/importlib_metadata/_meta.py index 0942bbd9..0c20eff3 100644 --- a/importlib_metadata/_meta.py +++ b/importlib_metadata/_meta.py @@ -1,15 +1,11 @@ from __future__ import annotations import os +from collections.abc import Iterator from typing import ( Any, - Dict, - Iterator, - List, - Optional, Protocol, TypeVar, - Union, overload, ) @@ -28,25 +24,25 @@ def __iter__(self) -> Iterator[str]: ... # pragma: no cover @overload def get( self, name: str, failobj: None = None - ) -> Optional[str]: ... # pragma: no cover + ) -> str | None: ... # pragma: no cover @overload - def get(self, name: str, failobj: _T) -> Union[str, _T]: ... # pragma: no cover + def get(self, name: str, failobj: _T) -> str | _T: ... # pragma: no cover # overload per python/importlib_metadata#435 @overload def get_all( self, name: str, failobj: None = None - ) -> Optional[List[Any]]: ... # pragma: no cover + ) -> list[Any] | None: ... # pragma: no cover @overload - def get_all(self, name: str, failobj: _T) -> Union[List[Any], _T]: + def get_all(self, name: str, failobj: _T) -> list[Any] | _T: """ Return all values associated with a possibly multi-valued key. """ @property - def json(self) -> Dict[str, Union[str, List[str]]]: + def json(self) -> dict[str, str | list[str]]: """ A JSON-compatible form of the metadata. """ @@ -58,11 +54,11 @@ class SimplePath(Protocol): """ def joinpath( - self, other: Union[str, os.PathLike[str]] + self, other: str | os.PathLike[str] ) -> SimplePath: ... # pragma: no cover def __truediv__( - self, other: Union[str, os.PathLike[str]] + self, other: str | os.PathLike[str] ) -> SimplePath: ... # pragma: no cover @property diff --git a/importlib_metadata/_typing.py b/importlib_metadata/_typing.py new file mode 100644 index 00000000..32b1d2b9 --- /dev/null +++ b/importlib_metadata/_typing.py @@ -0,0 +1,15 @@ +import functools +import typing + +from ._meta import PackageMetadata + +md_none = functools.partial(typing.cast, PackageMetadata) +""" +Suppress type errors for optional metadata. + +Although Distribution.metadata can return None when metadata is corrupt +and thus None, allow callers to assume it's not None and crash if +that's the case. + +# python/importlib_metadata#493 +""" diff --git a/importlib_metadata/compat/py39.py b/importlib_metadata/compat/py39.py index 1f15bd97..3eb9c01e 100644 --- a/importlib_metadata/compat/py39.py +++ b/importlib_metadata/compat/py39.py @@ -2,7 +2,9 @@ Compatibility layer with Python 3.8/3.9 """ -from typing import TYPE_CHECKING, Any, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover # Prevent circular imports on runtime. @@ -10,8 +12,10 @@ else: Distribution = EntryPoint = Any +from .._typing import md_none + -def normalized_name(dist: Distribution) -> Optional[str]: +def normalized_name(dist: Distribution) -> str | None: """ Honor name normalization for distributions that don't provide ``_normalized_name``. """ @@ -20,7 +24,9 @@ def normalized_name(dist: Distribution) -> Optional[str]: except AttributeError: from .. import Prepared # -> delay to prevent circular imports. - return Prepared.normalize(getattr(dist, "name", None) or dist.metadata['Name']) + return Prepared.normalize( + getattr(dist, "name", None) or md_none(dist.metadata)['Name'] + ) def ep_matches(ep: EntryPoint, **params) -> bool: diff --git a/ruff.toml b/ruff.toml index 9379d6e1..b52a6d7c 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,11 +3,33 @@ extend = "pyproject.toml" [lint] extend-select = [ - "C901", - "PERF401", - "W", + # upstream + + "C901", # complex-structure + "I", # isort + "PERF401", # manual-list-comprehension + "W", # pycodestyle Warning + + # Ensure modern type annotation syntax and best practices + # Not including those covered by type-checkers or exclusive to Python 3.11+ + "FA", # flake8-future-annotations + "F404", # late-future-import + "PYI", # flake8-pyi + "UP006", # non-pep585-annotation + "UP007", # non-pep604-annotation + "UP010", # unnecessary-future-import + "UP035", # deprecated-import + "UP037", # quoted-annotation + "UP043", # unnecessary-default-type-args + + # local ] ignore = [ + # upstream + + # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, + # irrelevant to this project. + "PYI011", # typed-argument-default-in-stub # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", @@ -23,6 +45,8 @@ ignore = [ "COM819", "ISC001", "ISC002", + + # local ] [format] diff --git a/tests/_path.py b/tests/_path.py index c66cf5f8..e63d889f 100644 --- a/tests/_path.py +++ b/tests/_path.py @@ -4,7 +4,8 @@ import functools import pathlib -from typing import TYPE_CHECKING, Mapping, Protocol, Union, runtime_checkable +from collections.abc import Mapping +from typing import TYPE_CHECKING, Protocol, Union, runtime_checkable if TYPE_CHECKING: from typing_extensions import Self diff --git a/tests/test_main.py b/tests/test_main.py index 7c9851fc..5ed08c89 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -155,6 +155,16 @@ def test_valid_dists_preferred(self): dist = Distribution.from_name('foo') assert dist.version == "1.0" + def test_missing_metadata(self): + """ + Dists with a missing metadata file should return None. + + Ref python/importlib_metadata#493. + """ + fixtures.build_files(self.make_pkg('foo-4.3', files={}), self.site_dir) + assert Distribution.from_name('foo').metadata is None + assert metadata('foo') is None + class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): @staticmethod