Repository: pypa/setuptools
Pull request #4838 (2025-02-18 13:05:41)

Add initial warnings regarding `license-files` glob patterns.
<!-- First time contributors: Take a moment to review https://setuptools.pypa.io/en/latest/development/developer-guide.html! -->
<!-- Remove sections if not applicable -->

>  The PEP specifies that tools should reject any patterns which contain not-approved chars.
 Furthermore, it should be an error if a pattern doesn't match any files.

Ref: https://github.com/pypa/setuptools/pull/4829#issuecomment-2663706134

This by no means introduces an exhaustive check, just the low hanging fruits.

I intentionally do not analyse each pattern individually here to avoid wreaking too much havoc (imagine e.g. project templates that adds a very general catch all list of patterns in `setup.cfg`).

Regarding the `glob` patterns, I am keeping loose for the time being, would like to avoid adding a regex for each pattern if possible, but open to it...

/cc @cdce8p 

### Pull Request Checklist
- [x] Changes have tests
- [ ] News fragment added in [`newsfragments/`].
  _(See [documentation][PR docs] for details)_


[`newsfragments/`]: https://github.com/pypa/setuptools/tree/master/newsfragments
[PR docs]:
https://setuptools.pypa.io/en/latest/development/developer-guide.html#making-a-pull-request


cdce8p: > I intentionally do not analyse each pattern individually here to avoid wreaking too much havoc (imagine e.g. project templates that adds a very general catch all list of patterns in `setup.cfg`).

I also started working on this before I saw you've opened this PR. As I chose a slightly different approach, I finished it so we could compare them better. See #4841

I explicitly decided to check that each glob pattern match at least one file as I think this is by far the most useful check to have. It's a fallback to make sure files actually get matched. I don't think project templates are a big deal. They either didn't add it in the first place as setuptools provided good defaults early on or used a value specific to their configuration. Why should they have added multiple patterns if they didn't use these files?
abravalheri: Thank you very much @cdce8p, I tried to incorporate the validations from https://github.com/pypa/setuptools/pull/4841 into this one according to the discussion in https://github.com/pypa/setuptools/pull/4841#issuecomment-2669174377.
abravalheri: Waiting feedback from https://discuss.python.org/t/pep-639-round-3-improving-license-clarity-with-better-package-metadata/53020/174.

Patch:
diff --git a/newsfragments/4838.feature.rst b/newsfragments/4838.feature.rst
new file mode 100644
index 000000000..31aa8ba43
--- /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 133948eb0..d457d5ebe 100644
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -6,8 +6,8 @@ import numbers
 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 @@ class Distribution(_Distribution):
             # 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
         """


Test patch:
diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py
index bcea9a884..489fd98e2 100644
--- a/setuptools/tests/config/test_apply_pyprojecttoml.py
+++ b/setuptools/tests/config/test_apply_pyprojecttoml.py
@@ -24,7 +24,7 @@ from setuptools.config import expand, pyprojecttoml, setupcfg
 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 @@ class TestLicenseFiles:
         (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 @@ class TestLicenseFiles:
         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 548cb869f..0d925111f 100644
--- a/setuptools/tests/test_core_metadata.py
+++ b/setuptools/tests/test_core_metadata.py
@@ -373,6 +373,9 @@ class TestParityWithMetadataFromPyPaWheel:
         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: