Skip to content

Commit 5bb11cb

Browse files
authored
Add initial warnings regarding license-files glob patterns (pypa#4838)
2 parents c30a41a + 7b3366d commit 5bb11cb

File tree

4 files changed

+97
-14
lines changed

4 files changed

+97
-14
lines changed

newsfragments/4838.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added simple validation for given glob patterns in ``license-files``:
2+
a warning will be generated if no file is matched.
3+
Invalid glob patterns can raise an exception.
4+
-- thanks :user:`cdce8p` for contributions.

setuptools/dist.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import os
77
import re
88
import sys
9-
from collections.abc import Iterable, MutableMapping, Sequence
10-
from glob import iglob
9+
from collections.abc import Iterable, Iterator, MutableMapping, Sequence
10+
from glob import glob
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING, Any, Union
1313

@@ -459,29 +459,79 @@ def _finalize_license_files(self) -> None:
459459
# See https://wheel.readthedocs.io/en/stable/user_guide.html
460460
# -> 'Including license files in the generated wheel file'
461461
patterns = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*']
462+
files = self._expand_patterns(patterns, enforce_match=False)
463+
else: # Patterns explicitly given by the user
464+
files = self._expand_patterns(patterns, enforce_match=True)
462465

463-
self.metadata.license_files = list(
464-
map(
465-
lambda path: path.replace(os.sep, "/"),
466-
unique_everseen(self._expand_patterns(patterns)),
467-
)
468-
)
466+
self.metadata.license_files = list(unique_everseen(files))
469467

470-
@staticmethod
471-
def _expand_patterns(patterns):
468+
@classmethod
469+
def _expand_patterns(
470+
cls, patterns: list[str], enforce_match: bool = True
471+
) -> Iterator[str]:
472472
"""
473473
>>> list(Distribution._expand_patterns(['LICENSE']))
474474
['LICENSE']
475475
>>> list(Distribution._expand_patterns(['pyproject.toml', 'LIC*']))
476476
['pyproject.toml', 'LICENSE']
477+
>>> list(Distribution._expand_patterns(['setuptools/**/pyprojecttoml.py']))
478+
['setuptools/config/pyprojecttoml.py']
477479
"""
478480
return (
479-
path
481+
path.replace(os.sep, "/")
480482
for pattern in patterns
481-
for path in sorted(iglob(pattern, recursive=True))
483+
for path in sorted(cls._find_pattern(pattern, enforce_match))
482484
if not path.endswith('~') and os.path.isfile(path)
483485
)
484486

487+
@staticmethod
488+
def _find_pattern(pattern: str, enforce_match: bool = True) -> list[str]:
489+
r"""
490+
>>> Distribution._find_pattern("LICENSE")
491+
['LICENSE']
492+
>>> Distribution._find_pattern("/LICENSE.MIT")
493+
Traceback (most recent call last):
494+
...
495+
setuptools.errors.InvalidConfigError: Pattern '/LICENSE.MIT' should be relative...
496+
>>> Distribution._find_pattern("../LICENSE.MIT")
497+
Traceback (most recent call last):
498+
...
499+
setuptools.errors.InvalidConfigError: ...Pattern '../LICENSE.MIT' cannot contain '..'
500+
>>> Distribution._find_pattern("LICEN{CSE*")
501+
Traceback (most recent call last):
502+
...
503+
setuptools.warnings.SetuptoolsDeprecationWarning: ...Pattern 'LICEN{CSE*' contains invalid characters...
504+
"""
505+
if ".." in pattern:
506+
raise InvalidConfigError(f"Pattern {pattern!r} cannot contain '..'")
507+
if pattern.startswith((os.sep, "/")) or ":\\" in pattern:
508+
raise InvalidConfigError(
509+
f"Pattern {pattern!r} should be relative and must not start with '/'"
510+
)
511+
if re.match(r'^[\w\-\.\/\*\?\[\]]+$', pattern) is None:
512+
pypa_guides = "specifications/pyproject-toml/#license-files"
513+
SetuptoolsDeprecationWarning.emit(
514+
"Please provide a valid glob pattern.",
515+
"Pattern {pattern!r} contains invalid characters.",
516+
pattern=pattern,
517+
see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
518+
due_date=(2026, 2, 20), # Introduced in 2025-02-20
519+
)
520+
521+
found = glob(pattern, recursive=True)
522+
523+
if enforce_match and not found:
524+
SetuptoolsDeprecationWarning.emit(
525+
"Cannot find any files for the given pattern.",
526+
"Pattern {pattern!r} did not match any files.",
527+
pattern=pattern,
528+
due_date=(2026, 2, 20), # Introduced in 2025-02-20
529+
# PEP 639 requires us to error, but as a transition period
530+
# we will only issue a warning to give people time to prepare.
531+
# After the transition, this should raise an InvalidConfigError.
532+
)
533+
return found
534+
485535
# FIXME: 'Distribution._parse_config_files' is too complex (14)
486536
def _parse_config_files(self, filenames=None): # noqa: C901
487537
"""

setuptools/tests/config/test_apply_pyprojecttoml.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter
2525
from setuptools.dist import Distribution
2626
from setuptools.errors import InvalidConfigError, RemovedConfigError
27-
from setuptools.warnings import SetuptoolsDeprecationWarning
27+
from setuptools.warnings import InformationOnly, SetuptoolsDeprecationWarning
2828

2929
from .downloads import retrieve_file, urls_from_file
3030

@@ -36,11 +36,22 @@ def makedist(path, **attrs):
3636
return Distribution({"src_root": path, **attrs})
3737

3838

39+
def _mock_expand_patterns(patterns, *_, **__):
40+
"""
41+
Allow comparing the given patterns for 2 dist objects.
42+
We need to strip special chars to avoid errors when validating.
43+
"""
44+
return [re.sub("[^a-z0-9]+", "", p, flags=re.I) or "empty" for p in patterns]
45+
46+
3947
@pytest.mark.parametrize("url", urls_from_file(HERE / EXAMPLES_FILE))
4048
@pytest.mark.filterwarnings("ignore")
4149
@pytest.mark.uses_network
4250
def test_apply_pyproject_equivalent_to_setupcfg(url, monkeypatch, tmp_path):
4351
monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.0.1"))
52+
monkeypatch.setattr(
53+
Distribution, "_expand_patterns", Mock(side_effect=_mock_expand_patterns)
54+
)
4455
setupcfg_example = retrieve_file(url)
4556
pyproject_example = Path(tmp_path, "pyproject.toml")
4657
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):
432443
(tmp_path / "_FILE.txt").touch()
433444
(tmp_path / "_FILE.rst").touch()
434445

435-
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
446+
msg = "Normalizing.*LicenseRef"
447+
with pytest.warns(InformationOnly, match=msg):
448+
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
449+
436450
assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"}
437451
assert dist.metadata.license is None
438452
assert dist.metadata.license_expression == "LicenseRef-Proprietary"
@@ -461,6 +475,18 @@ def test_default_patterns(self, tmp_path):
461475
assert (tmp_path / "LICENSE.txt").exists() # from base example
462476
assert set(dist.metadata.license_files) == {*license_files, "LICENSE.txt"}
463477

478+
def test_missing_patterns(self, tmp_path):
479+
pyproject = self.base_pyproject_license_pep639(tmp_path)
480+
assert list(tmp_path.glob("_FILE*")) == [] # sanity check
481+
482+
msg1 = "Cannot find any files for the given pattern.*"
483+
msg2 = "Normalizing 'licenseref-Proprietary' to 'LicenseRef-Proprietary'"
484+
with (
485+
pytest.warns(SetuptoolsDeprecationWarning, match=msg1),
486+
pytest.warns(InformationOnly, match=msg2),
487+
):
488+
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
489+
464490
def test_deprecated_file_expands_to_text(self, tmp_path):
465491
"""Make sure the old example with ``license = {text = ...}`` works"""
466492

setuptools/tests/test_core_metadata.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,9 @@ def dist(self, request, monkeypatch, tmp_path):
373373
monkeypatch.chdir(tmp_path)
374374
monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.42"))
375375
monkeypatch.setattr(expand, "read_files", Mock(return_value="hello world"))
376+
monkeypatch.setattr(
377+
Distribution, "_finalize_license_files", Mock(return_value=None)
378+
)
376379
if request.param is None:
377380
yield self.base_example()
378381
else:

0 commit comments

Comments
 (0)