Skip to content

Commit

Permalink
Validate license-files glob patterns
Browse files Browse the repository at this point in the history
  • Loading branch information
cdce8p committed Feb 18, 2025
1 parent 10c3e7c commit 6c0156f
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 14 deletions.
3 changes: 3 additions & 0 deletions newsfragments/4841.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added validation for given glob patterns in ``license-files``.
Raise an exception if it is invalid or doesn't match any
license files.
57 changes: 46 additions & 11 deletions setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
A poor approximation of an OrderedSequence (dict doesn't match a Sequence).
"""

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


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

license_file: str | None = self.metadata.license_file
if license_file and license_file not in patterns:
patterns.append(license_file)
skip_pattern_validation = True

if license_files is None and license_file is None:
# Default patterns match the ones wheel uses
# 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*']
skip_pattern_validation = True

matched_files = []
if skip_pattern_validation is True:
for pattern in patterns:
matched_files.extend(self._expand_pattern(pattern))
else:
for pattern in patterns:
matched_files.extend(self._validate_and_expand_pattern(pattern))

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(matched_files))

def _validate_and_expand_pattern(self, pattern):
"""Validate license file patterns according to the PyPA specifications.
https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files
"""
if ".." in pattern:
raise InvalidConfigError(
f"License file pattern '{pattern}' cannot contain '..'"
)
)
if pattern.startswith((os.sep, "/")) or ":\\" in pattern:
raise InvalidConfigError(
f"License file pattern '{pattern}' should be relative and "
"must not start with '/'"
)
if _license_files_allowed_chars.match(pattern) is None:
raise InvalidConfigError(
f"License file pattern '{pattern}' contains invalid "
"characters. "
"https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files"
)
found = list(self._expand_pattern(pattern))
if not found:
raise InvalidConfigError(
f"License file pattern '{pattern}' did not match any files."
)
return found

@staticmethod
def _expand_patterns(patterns):
def _expand_pattern(pattern):
"""
>>> list(Distribution._expand_patterns(['LICENSE']))
>>> list(Distribution._expand_pattern('LICENSE'))
['LICENSE']
>>> list(Distribution._expand_pattern('LIC*'))
['LICENSE']
>>> list(Distribution._expand_patterns(['pyproject.toml', 'LIC*']))
['pyproject.toml', 'LICENSE']
>>> list(Distribution._expand_pattern('setuptools/**/pyprojecttoml.py'))
['setuptools/config/pyprojecttoml.py']
"""
return (
path
for pattern in patterns
path.replace(os.sep, "/")
for path in sorted(iglob(pattern, recursive=True))
if not path.endswith('~') and os.path.isfile(path)
)
Expand Down
65 changes: 62 additions & 3 deletions setuptools/tests/config/test_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def makedist(path, **attrs):
@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, "_validate_and_expand_pattern", Mock(return_value=[])
)
setupcfg_example = retrieve_file(url)
pyproject_example = Path(tmp_path, "pyproject.toml")
setupcfg_text = setupcfg_example.read_text(encoding="utf-8")
Expand Down Expand Up @@ -390,17 +393,21 @@ def base_pyproject(
text,
count=1,
)
assert license_toml in text # sanity check
if r"\\" not in license_toml:
assert license_toml in text # sanity check
text = f"{text}\n{additional_text}\n"
pyproject = _pep621_example_project(tmp_path, "README", pyproject_text=text)
return pyproject

def base_pyproject_license_pep639(self, tmp_path, additional_text=""):
def base_pyproject_license_pep639(
self, tmp_path, additional_text="", *, license_files=None
):
license_files = license_files or '["_FILE*"]'
return self.base_pyproject(
tmp_path,
additional_text=additional_text,
license_toml='license = "licenseref-Proprietary"'
'\nlicense-files = ["_FILE*"]\n',
f'\nlicense-files = {license_files}\n',
)

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

def test_missing_patterns(self, tmp_path):
pyproject = self.base_pyproject_license_pep639(tmp_path)
assert list(tmp_path.glob("_FILE*")) == [] # sanity check

msg = r"License file pattern '_FILE\*' did not match any files."
with pytest.raises(InvalidConfigError, match=msg):
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)

@pytest.mark.parametrize(
("license_files", "msg"),
[
pytest.param(
'["../folder/LICENSE"]',
"License file pattern '../folder/LICENSE' cannot contain '..'",
id="..",
),
pytest.param(
'["folder/../LICENSE"]',
"License file pattern 'folder/../LICENSE' cannot contain '..'",
id="..2",
),
pytest.param(
'["/folder/LICENSE"]',
"License file pattern '/folder/LICENSE' should be "
"relative and must not start with '/'",
id="absolute-path",
),
pytest.param(
'["C:\\\\\\\\folder\\\\\\\\LICENSE"]',
r"License file pattern 'C:\\folder\\LICENSE' should be "
"relative and must not start with '/'",
id="absolute-path2",
),
pytest.param(
'["~LICENSE"]',
r"License file pattern '~LICENSE' contains invalid characters",
id="invalid_chars",
),
pytest.param(
'["LICENSE$"]',
r"License file pattern 'LICENSE\$' contains invalid characters",
id="invalid_chars2",
),
],
)
def test_validate_patterns(self, tmp_path, license_files, msg):
pyproject = self.base_pyproject_license_pep639(
tmp_path, license_files=license_files
)
with pytest.raises(InvalidConfigError, match=msg):
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)


class TestPyModules:
# https://github.com/pypa/setuptools/issues/4316
Expand Down
3 changes: 3 additions & 0 deletions setuptools/tests/test_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,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, "_validate_and_expand_pattern", Mock(return_value=[])
)
if request.param is None:
yield self.base_example()
else:
Expand Down
4 changes: 4 additions & 0 deletions setuptools/tests/test_egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,10 @@ def test_setup_cfg_license_files(
pypath=os.pathsep.join([env.paths['lib'], str(tmpdir_cwd)]),
)
egg_info_dir = os.path.join('.', 'foo.egg-info')
if "INVALID_LICENSE" in excl_licenses:
# Invalid license file patterns raise InvalidConfigError
assert not Path(egg_info_dir, "SOURCES.txt").is_file()
return

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

0 comments on commit 6c0156f

Please sign in to comment.