Skip to content

Commit 6c0156f

Browse files
committed
Validate license-files glob patterns
1 parent 10c3e7c commit 6c0156f

File tree

5 files changed

+118
-14
lines changed

5 files changed

+118
-14
lines changed

newsfragments/4841.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added validation for given glob patterns in ``license-files``.
2+
Raise an exception if it is invalid or doesn't match any
3+
license files.

setuptools/dist.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
A poor approximation of an OrderedSequence (dict doesn't match a Sequence).
7171
"""
7272

73+
_license_files_allowed_chars = re.compile(r'^[\w\-\.\/\*\?\[\]]+$')
74+
7375

7476
def __getattr__(name: str) -> Any: # pragma: no cover
7577
if name == "sequence":
@@ -438,35 +440,68 @@ def _finalize_license_files(self) -> None:
438440
"""Compute names of all license files which should be included."""
439441
license_files: list[str] | None = self.metadata.license_files
440442
patterns = license_files or []
443+
skip_pattern_validation = False
441444

442445
license_file: str | None = self.metadata.license_file
443446
if license_file and license_file not in patterns:
444447
patterns.append(license_file)
448+
skip_pattern_validation = True
445449

446450
if license_files is None and license_file is None:
447451
# Default patterns match the ones wheel uses
448452
# See https://wheel.readthedocs.io/en/stable/user_guide.html
449453
# -> 'Including license files in the generated wheel file'
450454
patterns = ['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*']
455+
skip_pattern_validation = True
456+
457+
matched_files = []
458+
if skip_pattern_validation is True:
459+
for pattern in patterns:
460+
matched_files.extend(self._expand_pattern(pattern))
461+
else:
462+
for pattern in patterns:
463+
matched_files.extend(self._validate_and_expand_pattern(pattern))
451464

452-
self.metadata.license_files = list(
453-
map(
454-
lambda path: path.replace(os.sep, "/"),
455-
unique_everseen(self._expand_patterns(patterns)),
465+
self.metadata.license_files = list(unique_everseen(matched_files))
466+
467+
def _validate_and_expand_pattern(self, pattern):
468+
"""Validate license file patterns according to the PyPA specifications.
469+
https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files
470+
"""
471+
if ".." in pattern:
472+
raise InvalidConfigError(
473+
f"License file pattern '{pattern}' cannot contain '..'"
456474
)
457-
)
475+
if pattern.startswith((os.sep, "/")) or ":\\" in pattern:
476+
raise InvalidConfigError(
477+
f"License file pattern '{pattern}' should be relative and "
478+
"must not start with '/'"
479+
)
480+
if _license_files_allowed_chars.match(pattern) is None:
481+
raise InvalidConfigError(
482+
f"License file pattern '{pattern}' contains invalid "
483+
"characters. "
484+
"https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files"
485+
)
486+
found = list(self._expand_pattern(pattern))
487+
if not found:
488+
raise InvalidConfigError(
489+
f"License file pattern '{pattern}' did not match any files."
490+
)
491+
return found
458492

459493
@staticmethod
460-
def _expand_patterns(patterns):
494+
def _expand_pattern(pattern):
461495
"""
462-
>>> list(Distribution._expand_patterns(['LICENSE']))
496+
>>> list(Distribution._expand_pattern('LICENSE'))
497+
['LICENSE']
498+
>>> list(Distribution._expand_pattern('LIC*'))
463499
['LICENSE']
464-
>>> list(Distribution._expand_patterns(['pyproject.toml', 'LIC*']))
465-
['pyproject.toml', 'LICENSE']
500+
>>> list(Distribution._expand_pattern('setuptools/**/pyprojecttoml.py'))
501+
['setuptools/config/pyprojecttoml.py']
466502
"""
467503
return (
468-
path
469-
for pattern in patterns
504+
path.replace(os.sep, "/")
470505
for path in sorted(iglob(pattern, recursive=True))
471506
if not path.endswith('~') and os.path.isfile(path)
472507
)

setuptools/tests/config/test_apply_pyprojecttoml.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def makedist(path, **attrs):
4141
@pytest.mark.uses_network
4242
def test_apply_pyproject_equivalent_to_setupcfg(url, monkeypatch, tmp_path):
4343
monkeypatch.setattr(expand, "read_attr", Mock(return_value="0.0.1"))
44+
monkeypatch.setattr(
45+
Distribution, "_validate_and_expand_pattern", Mock(return_value=[])
46+
)
4447
setupcfg_example = retrieve_file(url)
4548
pyproject_example = Path(tmp_path, "pyproject.toml")
4649
setupcfg_text = setupcfg_example.read_text(encoding="utf-8")
@@ -390,17 +393,21 @@ def base_pyproject(
390393
text,
391394
count=1,
392395
)
393-
assert license_toml in text # sanity check
396+
if r"\\" not in license_toml:
397+
assert license_toml in text # sanity check
394398
text = f"{text}\n{additional_text}\n"
395399
pyproject = _pep621_example_project(tmp_path, "README", pyproject_text=text)
396400
return pyproject
397401

398-
def base_pyproject_license_pep639(self, tmp_path, additional_text=""):
402+
def base_pyproject_license_pep639(
403+
self, tmp_path, additional_text="", *, license_files=None
404+
):
405+
license_files = license_files or '["_FILE*"]'
399406
return self.base_pyproject(
400407
tmp_path,
401408
additional_text=additional_text,
402409
license_toml='license = "licenseref-Proprietary"'
403-
'\nlicense-files = ["_FILE*"]\n',
410+
f'\nlicense-files = {license_files}\n',
404411
)
405412

406413
def test_both_license_and_license_files_defined(self, tmp_path):
@@ -478,6 +485,58 @@ def test_deprecated_file_expands_to_text(self, tmp_path):
478485
assert dist.metadata.license == "--- LICENSE stub ---"
479486
assert set(dist.metadata.license_files) == {"LICENSE.txt"} # auto-filled
480487

488+
def test_missing_patterns(self, tmp_path):
489+
pyproject = self.base_pyproject_license_pep639(tmp_path)
490+
assert list(tmp_path.glob("_FILE*")) == [] # sanity check
491+
492+
msg = r"License file pattern '_FILE\*' did not match any files."
493+
with pytest.raises(InvalidConfigError, match=msg):
494+
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
495+
496+
@pytest.mark.parametrize(
497+
("license_files", "msg"),
498+
[
499+
pytest.param(
500+
'["../folder/LICENSE"]',
501+
"License file pattern '../folder/LICENSE' cannot contain '..'",
502+
id="..",
503+
),
504+
pytest.param(
505+
'["folder/../LICENSE"]',
506+
"License file pattern 'folder/../LICENSE' cannot contain '..'",
507+
id="..2",
508+
),
509+
pytest.param(
510+
'["/folder/LICENSE"]',
511+
"License file pattern '/folder/LICENSE' should be "
512+
"relative and must not start with '/'",
513+
id="absolute-path",
514+
),
515+
pytest.param(
516+
'["C:\\\\\\\\folder\\\\\\\\LICENSE"]',
517+
r"License file pattern 'C:\\folder\\LICENSE' should be "
518+
"relative and must not start with '/'",
519+
id="absolute-path2",
520+
),
521+
pytest.param(
522+
'["~LICENSE"]',
523+
r"License file pattern '~LICENSE' contains invalid characters",
524+
id="invalid_chars",
525+
),
526+
pytest.param(
527+
'["LICENSE$"]',
528+
r"License file pattern 'LICENSE\$' contains invalid characters",
529+
id="invalid_chars2",
530+
),
531+
],
532+
)
533+
def test_validate_patterns(self, tmp_path, license_files, msg):
534+
pyproject = self.base_pyproject_license_pep639(
535+
tmp_path, license_files=license_files
536+
)
537+
with pytest.raises(InvalidConfigError, match=msg):
538+
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
539+
481540

482541
class TestPyModules:
483542
# https://github.com/pypa/setuptools/issues/4316

setuptools/tests/test_core_metadata.py

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

setuptools/tests/test_egg_info.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,10 @@ def test_setup_cfg_license_files(
844844
pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
845845
)
846846
egg_info_dir = os.path.join('.', 'foo.egg-info')
847+
if "INVALID_LICENSE" in excl_licenses:
848+
# Invalid license file patterns raise InvalidConfigError
849+
assert not Path(egg_info_dir, "SOURCES.txt").is_file()
850+
return
847851

848852
sources_text = Path(egg_info_dir, "SOURCES.txt").read_text(encoding="utf-8")
849853
sources_lines = [line.strip() for line in sources_text.splitlines()]

0 commit comments

Comments
 (0)