diff --git a/newsfragments/4838.feature.rst b/newsfragments/4838.feature.rst new file mode 100644 index 0000000000..31aa8ba43b --- /dev/null +++ b/newsfragments/4838.feature.rst @@ -0,0 +1,4 @@ +Added simple validation for given glob patterns in ``license-files``: +a warning will be generated if no file is matched. +Invalid glob patterns can raise an exception. +-- thanks :user:`cdce8p` for contributions. diff --git a/setuptools/dist.py b/setuptools/dist.py index 133948eb08..d457d5ebe7 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -6,8 +6,8 @@ import os import re import sys -from collections.abc import Iterable, MutableMapping, Sequence -from glob import iglob +from collections.abc import Iterable, Iterator, MutableMapping, Sequence +from glob import glob from pathlib import Path from typing import TYPE_CHECKING, Any, Union @@ -459,29 +459,79 @@ def _finalize_license_files(self) -> None: # See https://wheel.readthedocs.io/en/stable/user_guide.html # -> 'Including license files in the generated wheel file' patterns = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*'] + files = self._expand_patterns(patterns, enforce_match=False) + else: # Patterns explicitly given by the user + files = self._expand_patterns(patterns, enforce_match=True) - self.metadata.license_files = list( - map( - lambda path: path.replace(os.sep, "/"), - unique_everseen(self._expand_patterns(patterns)), - ) - ) + self.metadata.license_files = list(unique_everseen(files)) - @staticmethod - def _expand_patterns(patterns): + @classmethod + def _expand_patterns( + cls, patterns: list[str], enforce_match: bool = True + ) -> Iterator[str]: """ >>> list(Distribution._expand_patterns(['LICENSE'])) ['LICENSE'] >>> list(Distribution._expand_patterns(['pyproject.toml', 'LIC*'])) ['pyproject.toml', 'LICENSE'] + >>> list(Distribution._expand_patterns(['setuptools/**/pyprojecttoml.py'])) + ['setuptools/config/pyprojecttoml.py'] """ return ( - path + path.replace(os.sep, "/") for pattern in patterns - for path in sorted(iglob(pattern, recursive=True)) + for path in sorted(cls._find_pattern(pattern, enforce_match)) if not path.endswith('~') and os.path.isfile(path) ) + @staticmethod + def _find_pattern(pattern: str, enforce_match: bool = True) -> list[str]: + r""" + >>> Distribution._find_pattern("LICENSE") + ['LICENSE'] + >>> Distribution._find_pattern("/LICENSE.MIT") + Traceback (most recent call last): + ... + setuptools.errors.InvalidConfigError: Pattern '/LICENSE.MIT' should be relative... + >>> Distribution._find_pattern("../LICENSE.MIT") + Traceback (most recent call last): + ... + setuptools.errors.InvalidConfigError: ...Pattern '../LICENSE.MIT' cannot contain '..' + >>> Distribution._find_pattern("LICEN{CSE*") + Traceback (most recent call last): + ... + setuptools.warnings.SetuptoolsDeprecationWarning: ...Pattern 'LICEN{CSE*' contains invalid characters... + """ + if ".." in pattern: + raise InvalidConfigError(f"Pattern {pattern!r} cannot contain '..'") + if pattern.startswith((os.sep, "/")) or ":\\" in pattern: + raise InvalidConfigError( + f"Pattern {pattern!r} should be relative and must not start with '/'" + ) + if re.match(r'^[\w\-\.\/\*\?\[\]]+$', pattern) is None: + pypa_guides = "specifications/pyproject-toml/#license-files" + SetuptoolsDeprecationWarning.emit( + "Please provide a valid glob pattern.", + "Pattern {pattern!r} contains invalid characters.", + pattern=pattern, + see_url=f"https://packaging.python.org/en/latest/{pypa_guides}", + due_date=(2026, 2, 20), # Introduced in 2025-02-20 + ) + + found = glob(pattern, recursive=True) + + if enforce_match and not found: + SetuptoolsDeprecationWarning.emit( + "Cannot find any files for the given pattern.", + "Pattern {pattern!r} did not match any files.", + pattern=pattern, + due_date=(2026, 2, 20), # Introduced in 2025-02-20 + # PEP 639 requires us to error, but as a transition period + # we will only issue a warning to give people time to prepare. + # After the transition, this should raise an InvalidConfigError. + ) + return found + # FIXME: 'Distribution._parse_config_files' is too complex (14) def _parse_config_files(self, filenames=None): # noqa: C901 """ diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index bcea9a8847..489fd98e26 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -24,7 +24,7 @@ from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter from setuptools.dist import Distribution from setuptools.errors import InvalidConfigError, RemovedConfigError -from setuptools.warnings import SetuptoolsDeprecationWarning +from setuptools.warnings import InformationOnly, SetuptoolsDeprecationWarning from .downloads import retrieve_file, urls_from_file @@ -36,11 +36,22 @@ def makedist(path, **attrs): return Distribution({"src_root": path, **attrs}) +def _mock_expand_patterns(patterns, *_, **__): + """ + Allow comparing the given patterns for 2 dist objects. + We need to strip special chars to avoid errors when validating. + """ + return [re.sub("[^a-z0-9]+", "", p, flags=re.I) or "empty" for p in patterns] + + @pytest.mark.parametrize("url", urls_from_file(HERE / EXAMPLES_FILE)) @pytest.mark.filterwarnings("ignore") @pytest.mark.uses_network def test_apply_pyproject_equivalent_to_setupcfg(url, monkeypatch, tmp_path): monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.0.1")) + monkeypatch.setattr( + Distribution, "_expand_patterns", Mock(side_effect=_mock_expand_patterns) + ) setupcfg_example = retrieve_file(url) pyproject_example = Path(tmp_path, "pyproject.toml") setupcfg_text = setupcfg_example.read_text(encoding="utf-8") @@ -432,7 +443,10 @@ def test_both_license_and_license_files_defined_pep639(self, tmp_path): (tmp_path / "_FILE.txt").touch() (tmp_path / "_FILE.rst").touch() - dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + msg = "Normalizing.*LicenseRef" + with pytest.warns(InformationOnly, match=msg): + dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"} assert dist.metadata.license is None assert dist.metadata.license_expression == "LicenseRef-Proprietary" @@ -461,6 +475,18 @@ def test_default_patterns(self, tmp_path): assert (tmp_path / "LICENSE.txt").exists() # from base example assert set(dist.metadata.license_files) == {*license_files, "LICENSE.txt"} + def test_missing_patterns(self, tmp_path): + pyproject = self.base_pyproject_license_pep639(tmp_path) + assert list(tmp_path.glob("_FILE*")) == [] # sanity check + + msg1 = "Cannot find any files for the given pattern.*" + msg2 = "Normalizing 'licenseref-Proprietary' to 'LicenseRef-Proprietary'" + with ( + pytest.warns(SetuptoolsDeprecationWarning, match=msg1), + pytest.warns(InformationOnly, match=msg2), + ): + pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + def test_deprecated_file_expands_to_text(self, tmp_path): """Make sure the old example with ``license = {text = ...}`` works""" diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py index 548cb869f7..0d925111fa 100644 --- a/setuptools/tests/test_core_metadata.py +++ b/setuptools/tests/test_core_metadata.py @@ -373,6 +373,9 @@ def dist(self, request, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.42")) monkeypatch.setattr(expand, "read_files", Mock(return_value="hello world")) + monkeypatch.setattr( + Distribution, "_finalize_license_files", Mock(return_value=None) + ) if request.param is None: yield self.base_example() else: