diff --git a/docs/userguide/pyproject_config.rst b/docs/userguide/pyproject_config.rst index e988fec7ac..efc68603a9 100644 --- a/docs/userguide/pyproject_config.rst +++ b/docs/userguide/pyproject_config.rst @@ -49,7 +49,7 @@ The ``project`` table contains metadata fields as described by the readme = "README.rst" requires-python = ">=3.8" keywords = ["one", "two"] - license = {text = "BSD-3-Clause"} + license = "BSD-3-Clause" classifiers = [ "Framework :: Django", "Programming Language :: Python :: 3", diff --git a/newsfragments/4706.feature.rst b/newsfragments/4706.feature.rst new file mode 100644 index 0000000000..1d34f5f476 --- /dev/null +++ b/newsfragments/4706.feature.rst @@ -0,0 +1 @@ +Added initial support for license expression (`PEP 639 `_). -- by :user:`cdce8p` diff --git a/setuptools/_core_metadata.py b/setuptools/_core_metadata.py index 850cc409f7..5342186c0e 100644 --- a/setuptools/_core_metadata.py +++ b/setuptools/_core_metadata.py @@ -88,6 +88,7 @@ def read_pkg_file(self, file): self.url = _read_field_from_msg(msg, 'home-page') self.download_url = _read_field_from_msg(msg, 'download-url') self.license = _read_field_unescaped_from_msg(msg, 'license') + self.license_expression = _read_field_unescaped_from_msg(msg, 'license-expression') self.long_description = _read_field_unescaped_from_msg(msg, 'description') if self.long_description is None and self.metadata_version >= Version('2.1'): @@ -175,7 +176,7 @@ def write_field(key, value): if attr_val is not None: write_field(field, attr_val) - license = self.get_license() + license = self.license_expression or self.get_license() if license: write_field('License', rfc822_escape(license)) diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py index 331596bdd7..6bb2bea514 100644 --- a/setuptools/config/_apply_pyprojecttoml.py +++ b/setuptools/config/_apply_pyprojecttoml.py @@ -58,6 +58,7 @@ def apply(dist: Distribution, config: dict, filename: StrPath) -> Distribution: os.chdir(root_dir) try: dist._finalize_requires() + dist._finalize_license_expression() dist._finalize_license_files() finally: os.chdir(current_directory) @@ -181,16 +182,19 @@ def _long_description( dist._referenced_files.add(file) -def _license(dist: Distribution, val: dict, root_dir: StrPath | None): +def _license(dist: Distribution, val: str | dict, root_dir: StrPath | None): from setuptools.config import expand - if "file" in val: - # XXX: Is it completely safe to assume static? - value = expand.read_files([val["file"]], root_dir) - _set_config(dist, "license", _static.Str(value)) - dist._referenced_files.add(val["file"]) + if isinstance(val, str): + _set_config(dist, "license_expression", _static.Str(val)) else: - _set_config(dist, "license", _static.Str(val["text"])) + if "file" in val: + # XXX: Is it completely safe to assume static? + value = expand.read_files([val["file"]], root_dir) + _set_config(dist, "license", _static.Str(value)) + dist._referenced_files.add(val["file"]) + else: + _set_config(dist, "license", _static.Str(val["text"])) def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str): diff --git a/setuptools/dist.py b/setuptools/dist.py index c6a3468123..27e8095709 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Union from more_itertools import partition, unique_everseen +from packaging.licenses import canonicalize_license_expression from packaging.markers import InvalidMarker, Marker from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import Version @@ -288,6 +289,7 @@ class Distribution(_Distribution): 'long_description_content_type': lambda: None, 'project_urls': dict, 'provides_extras': dict, # behaves like an ordered set + 'license_expression': lambda: None, 'license_file': lambda: None, 'license_files': lambda: None, 'install_requires': list, @@ -402,6 +404,25 @@ def _normalize_requires(self): (k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items() ) + def _finalize_license_expression(self) -> None: + """Normalize license and license_expression.""" + license_expr = self.metadata.license_expression + if license_expr: + normalized = canonicalize_license_expression(license_expr) + if license_expr != normalized: + InformationOnly.emit(f"Normalizing '{license_expr}' to '{normalized}'") + self.metadata.license_expression = normalized + + for cl in self.metadata.get_classifiers(): + if not cl.startswith("License :: "): + continue + SetuptoolsDeprecationWarning.emit( + "License classifier are deprecated in favor of the license expression.", + f"Please remove the '{cl}' classifier.", + see_url="https://peps.python.org/pep-0639/", + due_date=(2027, 2, 17), # Introduced 2025-02-17 + ) + 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 @@ -655,6 +676,7 @@ def parse_config_files( pyprojecttoml.apply_configuration(self, filename, ignore_option_errors) self._finalize_requires() + self._finalize_license_expression() self._finalize_license_files() def fetch_build_eggs( diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index 20146b4a89..468a1ba01d 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -9,6 +9,7 @@ import io import re import tarfile +import warnings from inspect import cleandoc from pathlib import Path from unittest.mock import Mock @@ -24,6 +25,7 @@ from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter from setuptools.dist import Distribution from setuptools.errors import RemovedConfigError +from setuptools.warnings import SetuptoolsDeprecationWarning from .downloads import retrieve_file, urls_from_file @@ -156,6 +158,32 @@ def main_gui(): pass def main_tomatoes(): pass """ +PEP639_LICENSE_TEXT = """\ +[project] +name = "spam" +version = "2020.0.0" +authors = [ + {email = "hi@pradyunsg.me"}, + {name = "Tzu-Ping Chung"} +] +license = {text = "MIT"} +""" + +PEP639_LICENSE_EXPRESSION = """\ +[project] +name = "spam" +version = "2020.0.0" +authors = [ + {email = "hi@pradyunsg.me"}, + {name = "Tzu-Ping Chung"} +] +license = "mit or apache-2.0" # should be normalized in metadata +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", +] +""" + def _pep621_example_project( tmp_path, @@ -251,10 +279,70 @@ def test_utf8_maintainer_in_metadata( # issue-3663 assert f"Maintainer-email: {expected_maintainers_meta_value}" in content -class TestLicenseFiles: - # TODO: After PEP 639 is accepted, we have to move the license-files - # to the `project` table instead of `tool.setuptools` +@pytest.mark.parametrize( + ('pyproject_text', 'license', 'license_expression', 'content_str'), + ( + pytest.param( + PEP639_LICENSE_TEXT, + 'MIT', + None, + 'License: MIT', + id='license-text', + ), + pytest.param( + PEP639_LICENSE_EXPRESSION, + None, + 'MIT OR Apache-2.0', + 'License: MIT OR Apache-2.0', # TODO Metadata version '2.4' + id='license-expression', + ), + ), +) +def test_license_in_metadata( + license, + license_expression, + content_str, + pyproject_text, + tmp_path, +): + pyproject = _pep621_example_project( + tmp_path, + "README", + pyproject_text=pyproject_text, + ) + dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + assert dist.metadata.license == license + assert dist.metadata.license_expression == license_expression + pkg_file = tmp_path / "PKG-FILE" + with open(pkg_file, "w", encoding="utf-8") as fh: + dist.metadata.write_pkg_file(fh) + content = pkg_file.read_text(encoding="utf-8") + assert content_str in content + + +def test_license_expression_with_bad_classifier(tmp_path): + text = PEP639_LICENSE_EXPRESSION.rsplit("\n", 2)[0] + pyproject = _pep621_example_project( + tmp_path, + "README", + f"{text}\n \"License :: OSI Approved :: MIT License\"\n]", + ) + msg = "License classifier are deprecated(?:.|\n)*'License :: OSI Approved :: MIT License'" + with pytest.raises(SetuptoolsDeprecationWarning, match=msg): + pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SetuptoolsDeprecationWarning) + dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject) + # Check license classifier is still included + assert dist.metadata.get_classifiers() == [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "License :: OSI Approved :: MIT License", + ] + + +class TestLicenseFiles: def base_pyproject(self, tmp_path, additional_text): pyproject = _pep621_example_project(tmp_path, "README") text = pyproject.read_text(encoding="utf-8") @@ -267,6 +355,24 @@ def base_pyproject(self, tmp_path, additional_text): pyproject.write_text(text, encoding="utf-8") return pyproject + def base_pyproject_license_pep639(self, tmp_path): + pyproject = _pep621_example_project(tmp_path, "README") + text = pyproject.read_text(encoding="utf-8") + + # Sanity-check + assert 'license = {file = "LICENSE.txt"}' in text + assert 'license-files' not in text + assert "[tool.setuptools]" not in text + + text = re.sub( + r"(license = {file = \"LICENSE.txt\"})\n", + ("license = \"licenseref-Proprietary\"\nlicense-files = [\"_FILE*\"]\n"), + text, + count=1, + ) + pyproject.write_text(text, encoding="utf-8") + return pyproject + def test_both_license_and_license_files_defined(self, tmp_path): setuptools_config = '[tool.setuptools]\nlicense-files = ["_FILE*"]' pyproject = self.base_pyproject(tmp_path, setuptools_config) @@ -283,6 +389,18 @@ def test_both_license_and_license_files_defined(self, tmp_path): assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"} assert dist.metadata.license == "LicenseRef-Proprietary\n" + def test_both_license_and_license_files_defined_pep639(self, tmp_path): + # Set license and license-files + pyproject = self.base_pyproject_license_pep639(tmp_path) + + (tmp_path / "_FILE.txt").touch() + (tmp_path / "_FILE.rst").touch() + + 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" + def test_default_patterns(self, tmp_path): setuptools_config = '[tool.setuptools]\nzip-safe = false' # ^ used just to trigger section validation