Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -32,7 +32,7 @@
from setuptools._importlib import metadata
from setuptools.dist import Distribution

from distutils.dist import _OptionsList # Comes from typeshed

Check warning on line 35 in setuptools/config/_apply_pyprojecttoml.py

View workflow job for this annotation

GitHub Actions / pyright (3.13, ubuntu-latest)

Import "distutils.dist" could not be resolved from source (reportMissingModuleSource)


EMPTY: Mapping = MappingProxyType({}) # Immutable dict-like
Expand All @@ -58,6 +58,7 @@
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 @@
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
22 changes: 22 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,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
Expand Down Expand Up @@ -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(
Expand Down
124 changes: 121 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,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")
Expand All @@ -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)
Expand All @@ -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
Expand Down
Loading