diff --git a/newsfragments/4841.feature.rst b/newsfragments/4841.feature.rst new file mode 100644 index 0000000000..1163f20cd7 --- /dev/null +++ b/newsfragments/4841.feature.rst @@ -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. diff --git a/setuptools/dist.py b/setuptools/dist.py index d202dbf504..5b162c4ef4 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -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": @@ -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) ) diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index 848f44745f..92b7f80e01 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -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") @@ -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): @@ -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 diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py index b1edb79b40..e0390b38ac 100644 --- a/setuptools/tests/test_core_metadata.py +++ b/setuptools/tests/test_core_metadata.py @@ -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: diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py index 528e2c13d8..466a393d7d 100644 --- a/setuptools/tests/test_egg_info.py +++ b/setuptools/tests/test_egg_info.py @@ -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()]