Skip to content

Commit 016d24a

Browse files
authored
Add initial support for license expression (PEP 639) (#4706)
2 parents 62fab41 + 28baa9b commit 016d24a

File tree

6 files changed

+158
-12
lines changed

6 files changed

+158
-12
lines changed

docs/userguide/pyproject_config.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The ``project`` table contains metadata fields as described by the
4949
readme = "README.rst"
5050
requires-python = ">=3.8"
5151
keywords = ["one", "two"]
52-
license = {text = "BSD-3-Clause"}
52+
license = "BSD-3-Clause"
5353
classifiers = [
5454
"Framework :: Django",
5555
"Programming Language :: Python :: 3",

newsfragments/4706.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added initial support for license expression (`PEP 639 <https://peps.python.org/pep-0639/#add-license-expression-field>`_). -- by :user:`cdce8p`

setuptools/_core_metadata.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def read_pkg_file(self, file):
8888
self.url = _read_field_from_msg(msg, 'home-page')
8989
self.download_url = _read_field_from_msg(msg, 'download-url')
9090
self.license = _read_field_unescaped_from_msg(msg, 'license')
91+
self.license_expression = _read_field_unescaped_from_msg(msg, 'license-expression')
9192

9293
self.long_description = _read_field_unescaped_from_msg(msg, 'description')
9394
if self.long_description is None and self.metadata_version >= Version('2.1'):
@@ -175,7 +176,7 @@ def write_field(key, value):
175176
if attr_val is not None:
176177
write_field(field, attr_val)
177178

178-
license = self.get_license()
179+
license = self.license_expression or self.get_license()
179180
if license:
180181
write_field('License', rfc822_escape(license))
181182

setuptools/config/_apply_pyprojecttoml.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def apply(dist: Distribution, config: dict, filename: StrPath) -> Distribution:
5858
os.chdir(root_dir)
5959
try:
6060
dist._finalize_requires()
61+
dist._finalize_license_expression()
6162
dist._finalize_license_files()
6263
finally:
6364
os.chdir(current_directory)
@@ -181,16 +182,19 @@ def _long_description(
181182
dist._referenced_files.add(file)
182183

183184

184-
def _license(dist: Distribution, val: dict, root_dir: StrPath | None):
185+
def _license(dist: Distribution, val: str | dict, root_dir: StrPath | None):
185186
from setuptools.config import expand
186187

187-
if "file" in val:
188-
# XXX: Is it completely safe to assume static?
189-
value = expand.read_files([val["file"]], root_dir)
190-
_set_config(dist, "license", _static.Str(value))
191-
dist._referenced_files.add(val["file"])
188+
if isinstance(val, str):
189+
_set_config(dist, "license_expression", _static.Str(val))
192190
else:
193-
_set_config(dist, "license", _static.Str(val["text"]))
191+
if "file" in val:
192+
# XXX: Is it completely safe to assume static?
193+
value = expand.read_files([val["file"]], root_dir)
194+
_set_config(dist, "license", _static.Str(value))
195+
dist._referenced_files.add(val["file"])
196+
else:
197+
_set_config(dist, "license", _static.Str(val["text"]))
194198

195199

196200
def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str):

setuptools/dist.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import TYPE_CHECKING, Any, Union
1313

1414
from more_itertools import partition, unique_everseen
15+
from packaging.licenses import canonicalize_license_expression
1516
from packaging.markers import InvalidMarker, Marker
1617
from packaging.specifiers import InvalidSpecifier, SpecifierSet
1718
from packaging.version import Version
@@ -288,6 +289,7 @@ class Distribution(_Distribution):
288289
'long_description_content_type': lambda: None,
289290
'project_urls': dict,
290291
'provides_extras': dict, # behaves like an ordered set
292+
'license_expression': lambda: None,
291293
'license_file': lambda: None,
292294
'license_files': lambda: None,
293295
'install_requires': list,
@@ -402,6 +404,25 @@ def _normalize_requires(self):
402404
(k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items()
403405
)
404406

407+
def _finalize_license_expression(self) -> None:
408+
"""Normalize license and license_expression."""
409+
license_expr = self.metadata.license_expression
410+
if license_expr:
411+
normalized = canonicalize_license_expression(license_expr)
412+
if license_expr != normalized:
413+
InformationOnly.emit(f"Normalizing '{license_expr}' to '{normalized}'")
414+
self.metadata.license_expression = normalized
415+
416+
for cl in self.metadata.get_classifiers():
417+
if not cl.startswith("License :: "):
418+
continue
419+
SetuptoolsDeprecationWarning.emit(
420+
"License classifier are deprecated in favor of the license expression.",
421+
f"Please remove the '{cl}' classifier.",
422+
see_url="https://peps.python.org/pep-0639/",
423+
due_date=(2027, 2, 17), # Introduced 2025-02-17
424+
)
425+
405426
def _finalize_license_files(self) -> None:
406427
"""Compute names of all license files which should be included."""
407428
license_files: list[str] | None = self.metadata.license_files
@@ -655,6 +676,7 @@ def parse_config_files(
655676
pyprojecttoml.apply_configuration(self, filename, ignore_option_errors)
656677

657678
self._finalize_requires()
679+
self._finalize_license_expression()
658680
self._finalize_license_files()
659681

660682
def fetch_build_eggs(

setuptools/tests/config/test_apply_pyprojecttoml.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import io
1010
import re
1111
import tarfile
12+
import warnings
1213
from inspect import cleandoc
1314
from pathlib import Path
1415
from unittest.mock import Mock
@@ -24,6 +25,7 @@
2425
from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter
2526
from setuptools.dist import Distribution
2627
from setuptools.errors import RemovedConfigError
28+
from setuptools.warnings import SetuptoolsDeprecationWarning
2729

2830
from .downloads import retrieve_file, urls_from_file
2931

@@ -156,6 +158,32 @@ def main_gui(): pass
156158
def main_tomatoes(): pass
157159
"""
158160

161+
PEP639_LICENSE_TEXT = """\
162+
[project]
163+
name = "spam"
164+
version = "2020.0.0"
165+
authors = [
166+
{email = "[email protected]"},
167+
{name = "Tzu-Ping Chung"}
168+
]
169+
license = {text = "MIT"}
170+
"""
171+
172+
PEP639_LICENSE_EXPRESSION = """\
173+
[project]
174+
name = "spam"
175+
version = "2020.0.0"
176+
authors = [
177+
{email = "[email protected]"},
178+
{name = "Tzu-Ping Chung"}
179+
]
180+
license = "mit or apache-2.0" # should be normalized in metadata
181+
classifiers = [
182+
"Development Status :: 5 - Production/Stable",
183+
"Programming Language :: Python",
184+
]
185+
"""
186+
159187

160188
def _pep621_example_project(
161189
tmp_path,
@@ -251,10 +279,70 @@ def test_utf8_maintainer_in_metadata( # issue-3663
251279
assert f"Maintainer-email: {expected_maintainers_meta_value}" in content
252280

253281

254-
class TestLicenseFiles:
255-
# TODO: After PEP 639 is accepted, we have to move the license-files
256-
# to the `project` table instead of `tool.setuptools`
282+
@pytest.mark.parametrize(
283+
('pyproject_text', 'license', 'license_expression', 'content_str'),
284+
(
285+
pytest.param(
286+
PEP639_LICENSE_TEXT,
287+
'MIT',
288+
None,
289+
'License: MIT',
290+
id='license-text',
291+
),
292+
pytest.param(
293+
PEP639_LICENSE_EXPRESSION,
294+
None,
295+
'MIT OR Apache-2.0',
296+
'License: MIT OR Apache-2.0', # TODO Metadata version '2.4'
297+
id='license-expression',
298+
),
299+
),
300+
)
301+
def test_license_in_metadata(
302+
license,
303+
license_expression,
304+
content_str,
305+
pyproject_text,
306+
tmp_path,
307+
):
308+
pyproject = _pep621_example_project(
309+
tmp_path,
310+
"README",
311+
pyproject_text=pyproject_text,
312+
)
313+
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
314+
assert dist.metadata.license == license
315+
assert dist.metadata.license_expression == license_expression
316+
pkg_file = tmp_path / "PKG-FILE"
317+
with open(pkg_file, "w", encoding="utf-8") as fh:
318+
dist.metadata.write_pkg_file(fh)
319+
content = pkg_file.read_text(encoding="utf-8")
320+
assert content_str in content
321+
322+
323+
def test_license_expression_with_bad_classifier(tmp_path):
324+
text = PEP639_LICENSE_EXPRESSION.rsplit("\n", 2)[0]
325+
pyproject = _pep621_example_project(
326+
tmp_path,
327+
"README",
328+
f"{text}\n \"License :: OSI Approved :: MIT License\"\n]",
329+
)
330+
msg = "License classifier are deprecated(?:.|\n)*'License :: OSI Approved :: MIT License'"
331+
with pytest.raises(SetuptoolsDeprecationWarning, match=msg):
332+
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
257333

334+
with warnings.catch_warnings():
335+
warnings.simplefilter("ignore", SetuptoolsDeprecationWarning)
336+
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
337+
# Check license classifier is still included
338+
assert dist.metadata.get_classifiers() == [
339+
"Development Status :: 5 - Production/Stable",
340+
"Programming Language :: Python",
341+
"License :: OSI Approved :: MIT License",
342+
]
343+
344+
345+
class TestLicenseFiles:
258346
def base_pyproject(self, tmp_path, additional_text):
259347
pyproject = _pep621_example_project(tmp_path, "README")
260348
text = pyproject.read_text(encoding="utf-8")
@@ -267,6 +355,24 @@ def base_pyproject(self, tmp_path, additional_text):
267355
pyproject.write_text(text, encoding="utf-8")
268356
return pyproject
269357

358+
def base_pyproject_license_pep639(self, tmp_path):
359+
pyproject = _pep621_example_project(tmp_path, "README")
360+
text = pyproject.read_text(encoding="utf-8")
361+
362+
# Sanity-check
363+
assert 'license = {file = "LICENSE.txt"}' in text
364+
assert 'license-files' not in text
365+
assert "[tool.setuptools]" not in text
366+
367+
text = re.sub(
368+
r"(license = {file = \"LICENSE.txt\"})\n",
369+
("license = \"licenseref-Proprietary\"\nlicense-files = [\"_FILE*\"]\n"),
370+
text,
371+
count=1,
372+
)
373+
pyproject.write_text(text, encoding="utf-8")
374+
return pyproject
375+
270376
def test_both_license_and_license_files_defined(self, tmp_path):
271377
setuptools_config = '[tool.setuptools]\nlicense-files = ["_FILE*"]'
272378
pyproject = self.base_pyproject(tmp_path, setuptools_config)
@@ -283,6 +389,18 @@ def test_both_license_and_license_files_defined(self, tmp_path):
283389
assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"}
284390
assert dist.metadata.license == "LicenseRef-Proprietary\n"
285391

392+
def test_both_license_and_license_files_defined_pep639(self, tmp_path):
393+
# Set license and license-files
394+
pyproject = self.base_pyproject_license_pep639(tmp_path)
395+
396+
(tmp_path / "_FILE.txt").touch()
397+
(tmp_path / "_FILE.rst").touch()
398+
399+
dist = pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)
400+
assert set(dist.metadata.license_files) == {"_FILE.rst", "_FILE.txt"}
401+
assert dist.metadata.license is None
402+
assert dist.metadata.license_expression == "LicenseRef-Proprietary"
403+
286404
def test_default_patterns(self, tmp_path):
287405
setuptools_config = '[tool.setuptools]\nzip-safe = false'
288406
# ^ used just to trigger section validation

0 commit comments

Comments
 (0)