Skip to content

Commit c30a41a

Browse files
authored
Improve interop between Dynamic (METADATA) and dynamic (pyproject.toml) with license/license_expression/license_files (pypa#4842)
2 parents 0216462 + 282177c commit c30a41a

File tree

5 files changed

+97
-5
lines changed

5 files changed

+97
-5
lines changed

setuptools/_core_metadata.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,12 @@ def _distribution_fullname(name: str, version: str) -> str:
304304
"home-page": "url",
305305
"keywords": "keywords",
306306
"license": "license",
307-
# "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
307+
# XXX: License-File is complicated because the user gives globs that are expanded
308+
# during the build. Without special handling it is likely always
309+
# marked as Dynamic, which is an acceptable outcome according to:
310+
# https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
311+
"license-file": "license_files",
312+
"license-expression": "license_expression", # PEP 639
308313
"maintainer": "maintainer",
309314
"maintainer-email": "maintainer_email",
310315
"obsoletes": "obsoletes",

setuptools/config/_apply_pyprojecttoml.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ def _license(dist: Distribution, val: str | dict, root_dir: StrPath | None):
201201
from setuptools.config import expand
202202

203203
if isinstance(val, str):
204+
if getattr(dist.metadata, "license", None):
205+
SetuptoolsWarning.emit("`license` overwritten by `pyproject.toml`")
206+
dist.metadata.license = None
204207
_set_config(dist, "license_expression", _static.Str(val))
205208
else:
206209
pypa_guides = "guides/writing-pyproject-toml/#license"
@@ -459,7 +462,9 @@ def _acessor(obj):
459462
"description": _attrgetter("metadata.description"),
460463
"readme": _attrgetter("metadata.long_description"),
461464
"requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
462-
"license": _attrgetter("metadata.license"),
465+
"license": _some_attrgetter("metadata.license_expression", "metadata.license"),
466+
# XXX: `license-file` is currently not considered in the context of `dynamic`.
467+
# See TestPresetField.test_license_files_exempt_from_dynamic
463468
"authors": _some_attrgetter("metadata.author", "metadata.author_email"),
464469
"maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
465470
"keywords": _attrgetter("metadata.keywords"),
@@ -475,8 +480,11 @@ def _acessor(obj):
475480

476481
_RESET_PREVIOUSLY_DEFINED: dict = {
477482
# Fix improper setting: given in `setup.py`, but not listed in `dynamic`
483+
# Use "immutable" data structures to avoid in-place modification.
478484
# dict: pyproject name => value to which reset
479-
"license": _static.EMPTY_DICT,
485+
"license": "",
486+
# XXX: `license-file` is currently not considered in the context of `dynamic`.
487+
# See TestPresetField.test_license_files_exempt_from_dynamic
480488
"authors": _static.EMPTY_LIST,
481489
"maintainers": _static.EMPTY_LIST,
482490
"keywords": _static.EMPTY_LIST,

setuptools/dist.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,13 +406,24 @@ def _normalize_requires(self):
406406
)
407407

408408
def _finalize_license_expression(self) -> None:
409-
"""Normalize license and license_expression."""
409+
"""
410+
Normalize license and license_expression.
411+
>>> dist = Distribution({"license_expression": _static.Str("mit aNd gpl-3.0-OR-later")})
412+
>>> _static.is_static(dist.metadata.license_expression)
413+
True
414+
>>> dist._finalize_license_expression()
415+
>>> _static.is_static(dist.metadata.license_expression) # preserve "static-ness"
416+
True
417+
>>> print(dist.metadata.license_expression)
418+
MIT AND GPL-3.0-or-later
419+
"""
410420
classifiers = self.metadata.get_classifiers()
411421
license_classifiers = [cl for cl in classifiers if cl.startswith("License :: ")]
412422

413423
license_expr = self.metadata.license_expression
414424
if license_expr:
415-
normalized = canonicalize_license_expression(license_expr)
425+
str_ = _static.Str if _static.is_static(license_expr) else str
426+
normalized = str_(canonicalize_license_expression(license_expr))
416427
if license_expr != normalized:
417428
InformationOnly.emit(f"Normalizing '{license_expr}' to '{normalized}'")
418429
self.metadata.license_expression = normalized

setuptools/tests/config/test_apply_pyprojecttoml.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,11 @@ def pyproject(self, tmp_path, dynamic, extra_content=""):
554554
@pytest.mark.parametrize(
555555
("attr", "field", "value"),
556556
[
557+
("license_expression", "license", "MIT"),
558+
pytest.param(
559+
*("license", "license", "Not SPDX"),
560+
marks=[pytest.mark.filterwarnings("ignore:.*license. overwritten")],
561+
),
557562
("classifiers", "classifiers", ["Private :: Classifier"]),
558563
("entry_points", "scripts", {"console_scripts": ["foobar=foobar:main"]}),
559564
("entry_points", "gui-scripts", {"gui_scripts": ["bazquux=bazquux:main"]}),
@@ -579,6 +584,7 @@ def test_not_listed_in_dynamic(self, tmp_path, attr, field, value):
579584
@pytest.mark.parametrize(
580585
("attr", "field", "value"),
581586
[
587+
("license_expression", "license", "MIT"),
582588
("install_requires", "dependencies", []),
583589
("extras_require", "optional-dependencies", {}),
584590
("install_requires", "dependencies", ["six"]),
@@ -592,6 +598,26 @@ def test_listed_in_dynamic(self, tmp_path, attr, field, value):
592598
dist_value = _some_attrgetter(f"metadata.{attr}", attr)(dist)
593599
assert dist_value == value
594600

601+
def test_license_files_exempt_from_dynamic(self, monkeypatch, tmp_path):
602+
"""
603+
license-file is currently not considered in the context of dynamic.
604+
As per 2025-02-19, https://packaging.python.org/en/latest/specifications/pyproject-toml/#license-files
605+
allows setuptools to fill-in `license-files` the way it sees fit:
606+
607+
> If the license-files key is not defined, tools can decide how to handle license files.
608+
> For example they can choose not to include any files or use their own
609+
> logic to discover the appropriate files in the distribution.
610+
611+
Using license_files from setup.py to fill-in the value is in accordance
612+
with this rule.
613+
"""
614+
monkeypatch.chdir(tmp_path)
615+
pyproject = self.pyproject(tmp_path, [])
616+
dist = makedist(tmp_path, license_files=["LIC*"])
617+
(tmp_path / "LIC1").write_text("42", encoding="utf-8")
618+
dist = pyprojecttoml.apply_configuration(dist, pyproject)
619+
assert dist.metadata.license_files == ["LIC1"]
620+
595621
def test_warning_overwritten_dependencies(self, tmp_path):
596622
src = "[project]\nname='pkg'\nversion='0.1'\ndependencies=['click']\n"
597623
pyproject = tmp_path / "pyproject.toml"

setuptools/tests/test_core_metadata.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pathlib import Path
1313
from unittest.mock import Mock
1414

15+
import jaraco.path
1516
import pytest
1617
from packaging.metadata import Metadata
1718
from packaging.requirements import Requirement
@@ -442,6 +443,7 @@ class TestPEP643:
442443
readme = {text = "Long\\ndescription", content-type = "text/plain"}
443444
keywords = ["one", "two"]
444445
dependencies = ["requests"]
446+
license = "AGPL-3.0-or-later"
445447
[tool.setuptools]
446448
provides = ["abcd"]
447449
obsoletes = ["abcd"]
@@ -490,6 +492,46 @@ def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd):
490492
metadata = _get_metadata(dist)
491493
assert set(metadata.get_all("Dynamic")) == set(fields)
492494

495+
@pytest.mark.parametrize(
496+
"extra_toml",
497+
[
498+
"# Let setuptools autofill license-files",
499+
"license-files = ['LICENSE*', 'AUTHORS*', 'NOTICE']",
500+
],
501+
)
502+
def test_license_files_dynamic(self, extra_toml, tmpdir_cwd):
503+
# For simplicity (and for the time being) setuptools is not making
504+
# any special handling to guarantee `License-File` is considered static.
505+
# Instead we rely in the fact that, although suboptimal, it is OK to have
506+
# it as dynamics, as per:
507+
# https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
508+
files = {
509+
"pyproject.toml": self.STATIC_CONFIG["pyproject.toml"].replace(
510+
'license = "AGPL-3.0-or-later"',
511+
f"dynamic = ['license']\n{extra_toml}",
512+
),
513+
"LICENSE.md": "--- mock license ---",
514+
"NOTICE": "--- mock notice ---",
515+
"AUTHORS.txt": "--- me ---",
516+
}
517+
# Sanity checks:
518+
assert extra_toml in files["pyproject.toml"]
519+
assert 'license = "AGPL-3.0-or-later"' not in extra_toml
520+
521+
jaraco.path.build(files)
522+
dist = _makedist(license_expression="AGPL-3.0-or-later")
523+
metadata = _get_metadata(dist)
524+
assert set(metadata.get_all("Dynamic")) == {
525+
'license-file',
526+
'license-expression',
527+
}
528+
assert metadata.get("License-Expression") == "AGPL-3.0-or-later"
529+
assert set(metadata.get_all("License-File")) == {
530+
"NOTICE",
531+
"AUTHORS.txt",
532+
"LICENSE.md",
533+
}
534+
493535

494536
def _makedist(**attrs):
495537
dist = Distribution(attrs)

0 commit comments

Comments
 (0)