Repository: pypa/setuptools
Pull request #4842 (2025-02-19 13:49:22)

Improve interop between `Dynamic` (`METADATA`) and `dynamic` (`pyproject.toml`) with `license`/`license_expression`/`license_files`
<!-- First time contributors: Take a moment to review https://setuptools.pypa.io/en/latest/development/developer-guide.html! -->
<!-- Remove sections if not applicable -->

## Summary of changes

This PR improves the ongoing implementation of PEP 639 so that it plays nicely with all the restrictions regarding PEP 643 and `dynamic` in `pyproject.toml`...

Ref: https://github.com/pypa/setuptools/pull/4829#issuecomment-2666587077

### Pull Request Checklist
- [x] Changes have tests
- [ ] News fragment added in [`newsfragments/`].
  _(See [documentation][PR docs] for details)_


/cc @cdce8p 

[`newsfragments/`]: https://github.com/pypa/setuptools/tree/master/newsfragments
[PR docs]:
https://setuptools.pypa.io/en/latest/development/developer-guide.html#making-a-pull-request



Patch:
diff --git a/setuptools/_core_metadata.py b/setuptools/_core_metadata.py
index 60b47b375..975e9ceaa 100644
--- a/setuptools/_core_metadata.py
+++ b/setuptools/_core_metadata.py
@@ -304,7 +304,12 @@ _POSSIBLE_DYNAMIC_FIELDS = {
     "home-page": "url",
     "keywords": "keywords",
     "license": "license",
-    # "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
+    # XXX: License-File is complicated because the user gives globs that are expanded
+    #      during the build. Without special handling it is likely always
+    #      marked as Dynamic, which is an acceptable outcome according to:
+    #      https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
+    "license-file": "license_files",
+    "license-expression": "license_expression",  # PEP 639
     "maintainer": "maintainer",
     "maintainer-email": "maintainer_email",
     "obsoletes": "obsoletes",
diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py
index f23d0d2de..ffa3fc3c4 100644
--- a/setuptools/config/_apply_pyprojecttoml.py
+++ b/setuptools/config/_apply_pyprojecttoml.py
@@ -201,6 +201,9 @@ def _license(dist: Distribution, val: str | dict, root_dir: StrPath | None):
     from setuptools.config import expand
 
     if isinstance(val, str):
+        if getattr(dist.metadata, "license", None):
+            SetuptoolsWarning.emit("`license` overwritten by `pyproject.toml`")
+            dist.metadata.license = None
         _set_config(dist, "license_expression", _static.Str(val))
     else:
         pypa_guides = "guides/writing-pyproject-toml/#license"
@@ -459,7 +462,9 @@ _PREVIOUSLY_DEFINED = {
     "description": _attrgetter("metadata.description"),
     "readme": _attrgetter("metadata.long_description"),
     "requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
-    "license": _attrgetter("metadata.license"),
+    "license": _some_attrgetter("metadata.license_expression", "metadata.license"),
+    # XXX: `license-file` is currently not considered in the context of `dynamic`.
+    #      See TestPresetField.test_license_files_exempt_from_dynamic
     "authors": _some_attrgetter("metadata.author", "metadata.author_email"),
     "maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
     "keywords": _attrgetter("metadata.keywords"),
@@ -475,8 +480,11 @@ _PREVIOUSLY_DEFINED = {
 
 _RESET_PREVIOUSLY_DEFINED: dict = {
     # Fix improper setting: given in `setup.py`, but not listed in `dynamic`
+    # Use "immutable" data structures to avoid in-place modification.
     # dict: pyproject name => value to which reset
-    "license": _static.EMPTY_DICT,
+    "license": "",
+    # XXX: `license-file` is currently not considered in the context of `dynamic`.
+    #      See TestPresetField.test_license_files_exempt_from_dynamic
     "authors": _static.EMPTY_LIST,
     "maintainers": _static.EMPTY_LIST,
     "keywords": _static.EMPTY_LIST,
diff --git a/setuptools/dist.py b/setuptools/dist.py
index d202dbf50..133948eb0 100644
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -406,13 +406,24 @@ class Distribution(_Distribution):
         )
 
     def _finalize_license_expression(self) -> None:
-        """Normalize license and license_expression."""
+        """
+        Normalize license and license_expression.
+        >>> dist = Distribution({"license_expression": _static.Str("mit aNd  gpl-3.0-OR-later")})
+        >>> _static.is_static(dist.metadata.license_expression)
+        True
+        >>> dist._finalize_license_expression()
+        >>> _static.is_static(dist.metadata.license_expression)  # preserve "static-ness"
+        True
+        >>> print(dist.metadata.license_expression)
+        MIT AND GPL-3.0-or-later
+        """
         classifiers = self.metadata.get_classifiers()
         license_classifiers = [cl for cl in classifiers if cl.startswith("License :: ")]
 
         license_expr = self.metadata.license_expression
         if license_expr:
-            normalized = canonicalize_license_expression(license_expr)
+            str_ = _static.Str if _static.is_static(license_expr) else str
+            normalized = str_(canonicalize_license_expression(license_expr))
             if license_expr != normalized:
                 InformationOnly.emit(f"Normalizing '{license_expr}' to '{normalized}'")
                 self.metadata.license_expression = normalized


Test patch:
diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py
index 848f44745..bcea9a884 100644
--- a/setuptools/tests/config/test_apply_pyprojecttoml.py
+++ b/setuptools/tests/config/test_apply_pyprojecttoml.py
@@ -554,6 +554,11 @@ class TestPresetField:
     @pytest.mark.parametrize(
         ("attr", "field", "value"),
         [
+            ("license_expression", "license", "MIT"),
+            pytest.param(
+                *("license", "license", "Not SPDX"),
+                marks=[pytest.mark.filterwarnings("ignore:.*license. overwritten")],
+            ),
             ("classifiers", "classifiers", ["Private :: Classifier"]),
             ("entry_points", "scripts", {"console_scripts": ["foobar=foobar:main"]}),
             ("entry_points", "gui-scripts", {"gui_scripts": ["bazquux=bazquux:main"]}),
@@ -579,6 +584,7 @@ class TestPresetField:
     @pytest.mark.parametrize(
         ("attr", "field", "value"),
         [
+            ("license_expression", "license", "MIT"),
             ("install_requires", "dependencies", []),
             ("extras_require", "optional-dependencies", {}),
             ("install_requires", "dependencies", ["six"]),
@@ -592,6 +598,26 @@ class TestPresetField:
         dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
         assert dist_value == value
 
+    def test_license_files_exempt_from_dynamic(self, monkeypatch, tmp_path):
+        """
+        license-file is currently not considered in the context of dynamic.
+        As per 2025-02-19, https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files
+        allows setuptools to fill-in `license-files` the way it sees fit:
+
+        > If the license-files key is not defined, tools can decide how to handle license files.
+        > For example they can choose not to include any files or use their own
+        > logic to discover the appropriate files in the distribution.
+
+        Using license_files from setup.py to fill-in the value is in accordance
+        with this rule.
+        """
+        monkeypatch.chdir(tmp_path)
+        pyproject = self.pyproject(tmp_path, [])
+        dist = makedist(tmp_path, license_files=["LIC*"])
+        (tmp_path / "LIC1").write_text("42", encoding="utf-8")
+        dist = pyprojecttoml.apply_configuration(dist, pyproject)
+        assert dist.metadata.license_files == ["LIC1"]
+
     def test_warning_overwritten_dependencies(self, tmp_path):
         src = "[project]\nname='pkg'\nversion='0.1'\ndependencies=['click']\n"
         pyproject = tmp_path / "pyproject.toml"
diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py
index b1edb79b4..548cb869f 100644
--- a/setuptools/tests/test_core_metadata.py
+++ b/setuptools/tests/test_core_metadata.py
@@ -12,6 +12,7 @@ from inspect import cleandoc
 from pathlib import Path
 from unittest.mock import Mock
 
+import jaraco.path
 import pytest
 from packaging.metadata import Metadata
 from packaging.requirements import Requirement
@@ -442,6 +443,7 @@ class TestPEP643:
             readme = {text = "Long\\ndescription", content-type = "text/plain"}
             keywords = ["one", "two"]
             dependencies = ["requests"]
+            license = "AGPL-3.0-or-later"
             [tool.setuptools]
             provides = ["abcd"]
             obsoletes = ["abcd"]
@@ -490,6 +492,46 @@ class TestPEP643:
         metadata = _get_metadata(dist)
         assert set(metadata.get_all("Dynamic")) == set(fields)
 
+    @pytest.mark.parametrize(
+        "extra_toml",
+        [
+            "# Let setuptools autofill license-files",
+            "license-files = ['LICENSE*', 'AUTHORS*', 'NOTICE']",
+        ],
+    )
+    def test_license_files_dynamic(self, extra_toml, tmpdir_cwd):
+        # For simplicity (and for the time being) setuptools is not making
+        # any special handling to guarantee `License-File` is considered static.
+        # Instead we rely in the fact that, although suboptimal, it is OK to have
+        # it as dynamics, as per:
+        # https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
+        files = {
+            "pyproject.toml": self.STATIC_CONFIG["pyproject.toml"].replace(
+                'license = "AGPL-3.0-or-later"',
+                f"dynamic = ['license']\n{extra_toml}",
+            ),
+            "LICENSE.md": "--- mock license ---",
+            "NOTICE": "--- mock notice ---",
+            "AUTHORS.txt": "--- me ---",
+        }
+        # Sanity checks:
+        assert extra_toml in files["pyproject.toml"]
+        assert 'license = "AGPL-3.0-or-later"' not in extra_toml
+
+        jaraco.path.build(files)
+        dist = _makedist(license_expression="AGPL-3.0-or-later")
+        metadata = _get_metadata(dist)
+        assert set(metadata.get_all("Dynamic")) == {
+            'license-file',
+            'license-expression',
+        }
+        assert metadata.get("License-Expression") == "AGPL-3.0-or-later"
+        assert set(metadata.get_all("License-File")) == {
+            "NOTICE",
+            "AUTHORS.txt",
+            "LICENSE.md",
+        }
+
 
 def _makedist(**attrs):
     dist = Distribution(attrs)