Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,12 @@ def _distribution_fullname(name: str, version: str) -> str:
"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",
Expand Down
11 changes: 9 additions & 2 deletions setuptools/config/_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -459,7 +462,8 @@ def _acessor(obj):
"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: Should we wait until someone requires `license_files`?
"authors": _some_attrgetter("metadata.author", "metadata.author_email"),
"maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
"keywords": _attrgetter("metadata.keywords"),
Expand All @@ -475,8 +479,11 @@ def _acessor(obj):

_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,
Expand Down
15 changes: 13 additions & 2 deletions setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,24 @@ def _normalize_requires(self):
)

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
Expand Down
26 changes: 26 additions & 0 deletions setuptools/tests/config/test_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,11 @@ def pyproject(self, tmp_path, dynamic, extra_content=""):
@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"]}),
Expand All @@ -579,6 +584,7 @@ def test_not_listed_in_dynamic(self, tmp_path, attr, field, value):
@pytest.mark.parametrize(
("attr", "field", "value"),
[
("license_expression", "license", "MIT"),
("install_requires", "dependencies", []),
("extras_require", "optional-dependencies", {}),
("install_requires", "dependencies", ["six"]),
Expand All @@ -592,6 +598,26 @@ def test_listed_in_dynamic(self, tmp_path, attr, field, value):
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"
Expand Down
42 changes: 42 additions & 0 deletions setuptools/tests/test_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
from unittest.mock import Mock

import jaraco.path
import pytest
from packaging.metadata import Metadata
from packaging.requirements import Requirement
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -490,6 +492,46 @@ def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd):
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)
Expand Down
Loading