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

Implement PEP 639 #4829

Draft
wants to merge 47 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ef9b8e5
Store license-files in licenses subfolder
cdce8p Nov 3, 2024
02e1062
Update validate-pyproject to 0.23.0
cdce8p Nov 6, 2024
fe63c2f
Adjust test example
cdce8p Feb 16, 2025
3e9b9c7
Use os.sep for replace
cdce8p Feb 17, 2025
4cfa5fe
Merge pull request #4734 from cdce8p/validate-pyproject-0.22
abravalheri Feb 17, 2025
62fab41
Store license-files in licenses subfolder (#4728)
abravalheri Feb 17, 2025
285d681
Add initial support for License-Expression (PEP 639)
cdce8p Oct 28, 2024
420766c
Additional test case
cdce8p Nov 25, 2024
346bf17
Normalize license expression
cdce8p Nov 25, 2024
31d8340
Remove License-Expression field
cdce8p Nov 25, 2024
3744994
Review
cdce8p Feb 16, 2025
0d8f1f2
Replace error with warning and remove license classifier
cdce8p Feb 17, 2025
28baa9b
Revert removing the license classifier
cdce8p Feb 17, 2025
016d24a
Add initial support for license expression (PEP 639) (#4706)
abravalheri Feb 17, 2025
9bf4bb9
Bump core metadata version to 2.4
cdce8p Feb 17, 2025
6410380
Prevent deprecated license classifiers from being written to core met…
abravalheri Feb 17, 2025
778e679
Improve message in warning
abravalheri Feb 17, 2025
ee51110
Use a more explicit method for preserving static-ness of classifiers
abravalheri Feb 17, 2025
0a4eb8b
Bump core metadata version to 2.4 (#4830)
abravalheri Feb 17, 2025
9bdad9f
Add news fragment
abravalheri Feb 17, 2025
ea4095d
Keep warning about license classifiers but raise an error if license …
abravalheri Feb 17, 2025
3af67b8
Update newsfragment
abravalheri Feb 17, 2025
3b71b5f
Use a better docs URL for warning
abravalheri Feb 17, 2025
0587478
Ensure _apply_pyproject sets field on dist.metadata object not on dist
abravalheri Feb 17, 2025
29302de
Update URL for warning
abravalheri Feb 18, 2025
a20512e
Fix bypassed assertion in tests
abravalheri Feb 18, 2025
0d0d516
Ensure `_apply_pyproject` sets field on `dist.metadata` object not on…
abravalheri Feb 18, 2025
ab277d3
Deprecate tools.setuptools.license-files
cdce8p Feb 18, 2025
b80ee83
Prevent deprecated license classifiers from being written to core met…
abravalheri Feb 18, 2025
b44b4f1
Suggestions
cdce8p Feb 18, 2025
7e50cb1
Attempt to fix sphinx warnings
abravalheri Feb 18, 2025
7f44236
Attempt to improve display of pep links
abravalheri Feb 18, 2025
d6abdce
Fix reference
cdce8p Feb 18, 2025
eacde44
Address `sphinx` warning when building docs (#4839)
abravalheri Feb 18, 2025
6f0aee2
Deprecate `tools.setuptools.license-files` (#4837)
abravalheri Feb 18, 2025
62bd944
Add deprecation warning for project.license as a table in pyproject.toml
abravalheri Feb 18, 2025
e58f514
Adequate tests to warning
abravalheri Feb 18, 2025
68397dc
Add news fragment
abravalheri Feb 18, 2025
10c3e7c
Add warning for deprecated `project.license` as TOML table (#4840 )
abravalheri Feb 18, 2025
e0a6de5
Ensure _finalize_license_expression preserve "static-ness"
abravalheri Feb 19, 2025
9ba666d
Ensure PEP 639 implementation plays nicely with PEP 643
abravalheri Feb 19, 2025
17dc3df
Improve license/license_expression relationship with 'dynamic' in pyp…
abravalheri Feb 19, 2025
01396db
Add comments and test about dynamic x license_files
abravalheri Feb 19, 2025
282177c
Apply suggestions from code review
abravalheri Feb 19, 2025
0bb59c2
Update link in userguide
cdce8p Feb 20, 2025
0216462
Update link in userguide (#4844)
abravalheri Feb 20, 2025
c30a41a
Improve interop between `Dynamic` (`METADATA`) and `dynamic` (`pyproj…
abravalheri Feb 21, 2025
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/miscellaneous.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ files are included in a source distribution by default:
in ``pyproject.toml`` and/or equivalent in ``setup.cfg``/``setup.py``;
note that if you don't explicitly set this parameter, ``setuptools``
will include any files that match the following glob patterns:
``LICENSE*``, ``LICENCE*``, ``COPYING*``, ``NOTICE*``, ``AUTHORS**``;
``LICEN[CS]E*``, ``COPYING*``, ``NOTICE*``, ``AUTHORS**``;
- ``pyproject.toml``;
- ``setup.cfg``;
- ``setup.py``;
Expand Down
5 changes: 3 additions & 2 deletions 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 Expand Up @@ -99,7 +99,8 @@ Key Value Type (TOML) Notes
See :doc:`/userguide/datafiles`.
``exclude-package-data`` table/inline-table Empty by default. See :doc:`/userguide/datafiles`.
------------------------- --------------------------- -------------------------
``license-files`` array of glob patterns **Provisional** - likely to change with :pep:`639`
``license-files`` array of glob patterns **Deprecated** - use ``project.license-files`` instead. See
:external+PyPUG:ref:`Writing your pyproject.toml <license-files>`
(by default: ``['LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*']``)
``data-files`` table/inline-table **Discouraged** - check :doc:`/userguide/datafiles`.
Whenever possible, consider using data files inside the package directories.
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:`PEP 639 <639#add-license-expression-field>`). -- by :user:`cdce8p`
1 change: 1 addition & 0 deletions newsfragments/4728.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Store ``License-File``s in ``.dist-info/licenses`` subfolder and added support for recursive globs for ``license_files`` (:pep:`PEP 639 <639#add-license-expression-field>`). -- by :user:`cdce8p`
1 change: 1 addition & 0 deletions newsfragments/4734.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updated ``pyproject.toml`` validation via ``validate-pyproject`` v0.23.0.
1 change: 1 addition & 0 deletions newsfragments/4830.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bump core metadata version to ``2.4``. -- by :user:`cdce8p`
2 changes: 2 additions & 0 deletions newsfragments/4833.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added exception (or warning) when deprecated license classifiers are used,
according to `PEP 639 <https://peps.python.org/pep-0639/#deprecate-license-classifiers>`_.
3 changes: 3 additions & 0 deletions newsfragments/4837.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Deprecated ``tools.setuptools.license-files`` in favor of ``project.license-files``
and added exception if ``project.license-files`` and ``tools.setuptools.license-files``
are used together. -- by :user:`cdce8p`
5 changes: 5 additions & 0 deletions newsfragments/4840.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Deprecated ``project.license`` as a TOML table in
``pyproject.toml``. Users are expected to move towards using
``project.license-files`` and/or SPDX expressions (as strings) in
``pyproject.license``.
See :pep:`PEP 639 <639#deprecate-license-key-table-subkeys>`.
15 changes: 11 additions & 4 deletions setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
def get_metadata_version(self):
mv = getattr(self, 'metadata_version', None)
if mv is None:
mv = Version('2.2')
mv = Version('2.4')
self.metadata_version = mv
return mv

Expand Down 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,8 +176,9 @@ def write_field(key, value):
if attr_val is not None:
write_field(field, attr_val)

license = self.get_license()
if license:
if license_expression := self.license_expression:
write_field('License-Expression', license_expression)
elif license := self.get_license():
write_field('License', rfc822_escape(license))

for label, url in self.project_urls.items():
Expand Down Expand Up @@ -302,7 +304,12 @@ def _distribution_fullname(name: str, version: str) -> str:
"home-page": "url",
"keywords": "keywords",
"license": "license",
# "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
# XXX: License-File is complicated because the user gives globs that are expanded
# during the build. Without special handling it is likely always
# marked as Dynamic, which is an acceptable outcome according to:
# https://github.com/pypa/setuptools/issues/4629#issuecomment-2331233677
"license-file": "license_files",
"license-expression": "license_expression", # PEP 639
"maintainer": "maintainer",
"maintainer-email": "maintainer_email",
"obsoletes": "obsoletes",
Expand Down
6 changes: 4 additions & 2 deletions setuptools/command/bdist_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,11 @@ def adios(p: str) -> None:
metadata_path = os.path.join(distinfo_path, "METADATA")
shutil.copy(pkginfo_path, metadata_path)

licenses_folder_path = os.path.join(distinfo_path, "licenses")
for license_path in self.license_paths:
filename = os.path.basename(license_path)
shutil.copy(license_path, os.path.join(distinfo_path, filename))
dist_info_license_path = os.path.join(licenses_folder_path, license_path)
os.makedirs(os.path.dirname(dist_info_license_path), exist_ok=True)
shutil.copy(license_path, dist_info_license_path)

adios(egginfo_path)

Expand Down
58 changes: 47 additions & 11 deletions setuptools/config/_apply_pyprojecttoml.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@

from .. import _static
from .._path import StrPath
from ..errors import RemovedConfigError
from ..errors import InvalidConfigError, RemovedConfigError
from ..extension import Extension
from ..warnings import SetuptoolsWarning
from ..warnings import SetuptoolsDeprecationWarning, SetuptoolsWarning

if TYPE_CHECKING:
from typing_extensions import TypeAlias
Expand All @@ -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)

Check warning on line 35 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 @@ -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 @@ -88,6 +89,21 @@
if not tool_table:
return # short-circuit

if "license-files" in tool_table:
if dist.metadata.license_files:
raise InvalidConfigError(
"'project.license-files' is defined already. "
"Remove 'tool.setuptools.license-files'."
)

pypa_guides = "guides/writing-pyproject-toml/#license-files"
SetuptoolsDeprecationWarning.emit(
"'tool.setuptools.license-files' is deprecated in favor of "
"'project.license-files'",
see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 2, 18), # Warning introduced on 2025-02-18
)

for field, value in tool_table.items():
norm_key = json_compatible_key(field)

Expand Down Expand Up @@ -181,16 +197,30 @@
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):
if getattr(dist.metadata, "license", None):
SetuptoolsWarning.emit("`license` overwritten by `pyproject.toml`")
dist.metadata.license = None
_set_config(dist, "license_expression", _static.Str(val))
else:
_set_config(dist, "license", _static.Str(val["text"]))
pypa_guides = "guides/writing-pyproject-toml/#license"
SetuptoolsDeprecationWarning.emit(
"`project.license` as a TOML table is deprecated",
"Please use a simple string containing a SPDX expression for "
"`project.license`. You can also use `project.license-files`.",
see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",
due_date=(2026, 2, 18), # Introduced on 2025-02-18
)
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"]))


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

Check warning on line 398 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 Expand Up @@ -419,6 +449,7 @@
"provides_extras",
"license_file",
"license_files",
"license_expression",
}

_PREPROCESS = {
Expand All @@ -431,7 +462,9 @@
"description": _attrgetter("metadata.description"),
"readme": _attrgetter("metadata.long_description"),
"requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
"license": _attrgetter("metadata.license"),
"license": _some_attrgetter("metadata.license_expression", "metadata.license"),
# XXX: `license-file` is currently not considered in the context of `dynamic`.
# See TestPresetField.test_license_files_exempt_from_dynamic
"authors": _some_attrgetter("metadata.author", "metadata.author_email"),
"maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
"keywords": _attrgetter("metadata.keywords"),
Expand All @@ -447,8 +480,11 @@

_RESET_PREVIOUSLY_DEFINED: dict = {
# Fix improper setting: given in `setup.py`, but not listed in `dynamic`
# Use "immutable" data structures to avoid in-place modification.
# dict: pyproject name => value to which reset
"license": _static.EMPTY_DICT,
"license": "",
# XXX: `license-file` is currently not considered in the context of `dynamic`.
# See TestPresetField.test_license_files_exempt_from_dynamic
"authors": _static.EMPTY_LIST,
"maintainers": _static.EMPTY_LIST,
"keywords": _static.EMPTY_LIST,
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
Loading
Loading