Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion docs/userguide/pyproject_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions newsfragments/4706.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added initial support for license expression (`PEP 639 <https://peps.python.org/pep-0639/#add-license-expression-field>`_). -- by :user:`cdce8p`
3 changes: 2 additions & 1 deletion setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down Expand Up @@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this mean that we need a follow-up PR for the License-Expression, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. This PR was designed so that it itself didn't require a metadata_version bump. That could / should happen at a later point once all pieces are in place. I.e. setuptools has adopted version 2.3, the license files are stored in the correct folder and license expression parsing is handled correctly.

if license:
write_field('License', rfc822_escape(license))

Expand Down
18 changes: 11 additions & 7 deletions setuptools/config/_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"]))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to flatten this nested else{if, else} into a elif,else as in the suggested change:

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this as well. However I decided to use to nesting to clearly separate the before / after PEP 639 cases. In a flattened list it just looks like they are equal which isn't the case.

At some point, it might also make sense to add a DeprecationWarning for the legacy case which will be easier with the nested style.



def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str):
Expand Down
28 changes: 28 additions & 0 deletions setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -402,6 +404,31 @@ 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

classifiers = []
license_classifiers_found = False
for cl in self.metadata.get_classifiers():
if not cl.startswith("License :: "):
classifiers.append(cl)
continue
license_classifiers_found = True
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
)
if license_classifiers_found:
self.metadata.set_classifiers(classifiers)
Copy link
Contributor

@abravalheri abravalheri Feb 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if the user does not use a license expression in pyproject.toml, we are still going to emit license classifiers in the core metadata right? (because the for-loop is nested inside the if license_expr).

Should we *always skip license classifiers (with the warning) even if the project does not explicitly use a license expression (e.g. instead it relies on the license files)?

(It might also be the case your intention is to introduce a more timid version of the skip/warning in this PR, and later un-nest it and make it more prominent in a follow up. Please let me know if that is the case).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I don't think setuptools should modify the classifiers in the first place. We should either emit an error or a warning to the user to do so themselves. Everything else would just be surprising in the end. Pushed a new commit to revert that part.

(It might also be the case your intention is to introduce a more timid version of the skip/warning in this PR, and later un-nest it and make it more prominent in a follow up. Please let me know if that is the case).

At some point it might make sense to add another warning to nudge users to use license expressions. However, I do not plan to add it at this time.


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
Expand Down Expand Up @@ -655,6 +682,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(
Expand Down
123 changes: 120 additions & 3 deletions setuptools/tests/config/test_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -156,6 +158,32 @@ def main_gui(): pass
def main_tomatoes(): pass
"""

PEP639_LICENSE_TEXT = """\
[project]
name = "spam"
version = "2020.0.0"
authors = [
{email = "[email protected]"},
{name = "Tzu-Ping Chung"}
]
license = {text = "MIT"}
"""

PEP639_LICENSE_EXPRESSION = """\
[project]
name = "spam"
version = "2020.0.0"
authors = [
{email = "[email protected]"},
{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,
Expand Down Expand Up @@ -251,10 +279,69 @@ 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 :: OSI Approved :: MIT License' is removed
assert dist.metadata.get_classifiers() == [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
]


class TestLicenseFiles:
def base_pyproject(self, tmp_path, additional_text):
pyproject = _pep621_example_project(tmp_path, "README")
text = pyproject.read_text(encoding="utf-8")
Expand All @@ -267,6 +354,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)
Expand All @@ -283,6 +388,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
Expand Down
Loading