Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial support for license expression (PEP 639) #4706

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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`
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ authors = [
]
description = "Easily download, build, install, upgrade, and uninstall Python packages"
readme = "README.rst"
license = "MIT"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Software Development :: Libraries :: Python Modules",
Expand Down
3 changes: 2 additions & 1 deletion setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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 @@ -174,7 +175,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()
if license:
write_field('License', rfc822_escape(license))

Expand Down
14 changes: 9 additions & 5 deletions setuptools/config/_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from setuptools._importlib import metadata
from setuptools.dist import Distribution

from distutils.dist import _OptionsList # Comes from typeshed

Check warning on line 34 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)

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

View workflow job for this annotation

GitHub Actions / pyright (3.13, ubuntu-latest)

"_OptionsList" is unknown import symbol (reportAttributeAccessIssue)


EMPTY: Mapping = MappingProxyType({}) # Immutable dict-like
Expand All @@ -57,6 +57,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 @@ -174,14 +175,17 @@
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:
_set_config(dist, "license", expand.read_files([val["file"]], root_dir))
dist._referenced_files.add(val["file"])
if isinstance(val, str):
_set_config(dist, "license_expression", val)
else:
_set_config(dist, "license", val["text"])
if "file" in val:
_set_config(dist, "license", expand.read_files([val["file"]], root_dir))
dist._referenced_files.add(val["file"])
else:
_set_config(dist, "license", val["text"])


def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str):
Expand Down Expand Up @@ -345,7 +349,7 @@
>>> _attrgetter("d")(obj) is None
True
"""
return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))

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

View workflow job for this annotation

GitHub Actions / pyright (3.13, ubuntu-latest)

No overloads for "getattr" match the provided arguments (reportCallIssue)


def _some_attrgetter(*items):
Expand Down
2 changes: 1 addition & 1 deletion setuptools/config/_validate_pyproject/NOTICE
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
The code contained in this directory was automatically generated using the
following command:

python -m validate_pyproject.pre_compile --output-dir=setuptools/config/_validate_pyproject --enable-plugins setuptools distutils --very-verbose -t distutils=setuptools/config/distutils.schema.json -t setuptools=setuptools/config/setuptools.schema.json
python -m validate_pyproject.pre_compile --output-dir=setuptools/config/_validate_pyproject --enable-plugins setuptools distutils --very-verbose -t setuptools=setuptools/config/setuptools.schema.json -t distutils=setuptools/config/distutils.schema.json

Please avoid changing it manually.

Expand Down
32 changes: 31 additions & 1 deletion setuptools/config/_validate_pyproject/extra_validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class RedefiningStaticFieldAsDynamic(ValidationError):
)


class IncludedDependencyGroupMustExist(ValidationError):
_DESC = """An included dependency group must exist and must not be cyclic.
"""
__doc__ = _DESC
_URL = "https://peps.python.org/pep-0735/"


def validate_project_dynamic(pyproject: T) -> T:
project_table = pyproject.get("project", {})
dynamic = project_table.get("dynamic", [])
Expand All @@ -49,4 +56,27 @@ def validate_project_dynamic(pyproject: T) -> T:
return pyproject


EXTRA_VALIDATIONS = (validate_project_dynamic,)
def validate_include_depenency(pyproject: T) -> T:
dependency_groups = pyproject.get("dependency-groups", {})
for key, value in dependency_groups.items():
for each in value:
if (
isinstance(each, dict)
and (include_group := each.get("include-group"))
and include_group not in dependency_groups
):
raise IncludedDependencyGroupMustExist(
message=f"The included dependency group {include_group} doesn't exist",
value=each,
name=f"data.dependency_groups.{key}",
definition={
"description": cleandoc(IncludedDependencyGroupMustExist._DESC),
"see": IncludedDependencyGroupMustExist._URL,
},
rule="PEP 735",
)
# TODO: check for `include-group` cycles (can be conditional to graphlib)
return pyproject


EXTRA_VALIDATIONS = (validate_project_dynamic, validate_include_depenency)
351 changes: 222 additions & 129 deletions setuptools/config/_validate_pyproject/fastjsonschema_validations.py

Large diffs are not rendered by default.

33 changes: 30 additions & 3 deletions setuptools/config/_validate_pyproject/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,15 @@ class _TroveClassifier:
"""

downloaded: typing.Union[None, "Literal[False]", typing.Set[str]]
"""
None => not cached yet
False => unavailable
set => cached values
"""

def __init__(self) -> None:
self.downloaded = None
self._skip_download = False
# None => not cached yet
# False => cache not available
self.__name__ = "trove_classifier" # Emulate a public function

def _disable_download(self) -> None:
Expand Down Expand Up @@ -351,7 +354,7 @@ def python_entrypoint_reference(value: str) -> bool:
obj = rest

module_parts = module.split(".")
identifiers = _chain(module_parts, obj.split(".")) if rest else module_parts
identifiers = _chain(module_parts, obj.split(".")) if rest else iter(module_parts)
return all(python_identifier(i.strip()) for i in identifiers)


Expand All @@ -373,3 +376,27 @@ def uint(value: builtins.int) -> bool:
def int(value: builtins.int) -> bool:
r"""Signed 64-bit integer (:math:`-2^{63} \leq x < 2^{63}`)"""
return -(2**63) <= value < 2**63


try:
from packaging import licenses as _licenses

def SPDX(value: str) -> bool:
"""See :ref:`PyPA's License-Expression specification
<pypa:core-metadata-license-expression>` (added in :pep:`639`).
"""
try:
_licenses.canonicalize_license_expression(value)
return True
except _licenses.InvalidLicenseExpression:
return False

except ImportError: # pragma: no cover
_logger.warning(
"Could not find an up-to-date installation of `packaging`. "
"License expressions might not be validated. "
"To enforce validation, please install `packaging>=24.2`."
)

def SPDX(value: str) -> bool:
return True
21 changes: 21 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 All @@ -26,6 +27,7 @@
from ._reqs import _StrOrIter
from .config import pyprojecttoml, setupcfg
from .discovery import ConfigDiscovery
from .errors import InvalidConfigError
from .monkey import get_unpatched
from .warnings import InformationOnly, SetuptoolsDeprecationWarning

Expand Down Expand Up @@ -288,6 +290,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 @@ -397,6 +400,23 @@ 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
raise InvalidConfigError(
"License classifier are deprecated in favor of the license expression. "
f"Remove the '{cl}' classifier."
)

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 @@ -648,6 +668,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
113 changes: 109 additions & 4 deletions setuptools/tests/config/test_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from setuptools.config import expand, pyprojecttoml, setupcfg
from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter
from setuptools.dist import Distribution
from setuptools.errors import RemovedConfigError
from setuptools.errors import InvalidConfigError, RemovedConfigError

from .downloads import retrieve_file, urls_from_file

Expand Down Expand Up @@ -155,6 +155,31 @@ def main_gui(): pass
def main_tomatoes(): pass
"""

PEP639_LICENSE_TEXT = """\
[project]
name = "spam"
version = "2020.0.0"
authors = [
{email = "hi@pradyunsg.me"},
{name = "Tzu-Ping Chung"}
]
license = {text = "MIT"}
"""

PEP639_LICENSE_EXPRESSION = """\
[project]
name = "spam"
version = "2020.0.0"
authors = [
{email = "hi@pradyunsg.me"},
{name = "Tzu-Ping Chung"}
]
license = "mit or apache-2.0" # should be normalized in metadata
classifiers = [
"Development Status :: 5 - Production/Stable",
]
"""


def _pep621_example_project(
tmp_path,
Expand Down Expand Up @@ -250,10 +275,60 @@ 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.*'License :: OSI Approved :: MIT License'"
with pytest.raises(InvalidConfigError, match=msg):
pyprojecttoml.apply_configuration(makedist(tmp_path), pyproject)


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 @@ -266,6 +341,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 @@ -282,6 +375,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
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ commands =
[testenv:generate-validation-code]
skip_install = True
deps =
validate-pyproject[all]==0.19
validate-pyproject[all]==0.23
commands =
python -m tools.generate_validation_code

Expand Down
Loading