Skip to content

Commit f285d01

Browse files
authored
Implement PEP 643 (Dynamic field for core metadata) (#4698)
2 parents 27475f9 + a50f6e2 commit f285d01

10 files changed

+471
-59
lines changed

setuptools/_core_metadata.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from packaging.version import Version
2020

2121
from . import _normalization, _reqs
22+
from ._static import is_static
2223
from .warnings import SetuptoolsDeprecationWarning
2324

2425
from distutils.util import rfc822_escape
@@ -27,7 +28,7 @@
2728
def get_metadata_version(self):
2829
mv = getattr(self, 'metadata_version', None)
2930
if mv is None:
30-
mv = Version('2.1')
31+
mv = Version('2.2')
3132
self.metadata_version = mv
3233
return mv
3334

@@ -207,6 +208,10 @@ def write_field(key, value):
207208
self._write_list(file, 'License-File', self.license_files or [])
208209
_write_requirements(self, file)
209210

211+
for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
212+
if (val := getattr(self, attr, None)) and not is_static(val):
213+
write_field('Dynamic', field)
214+
210215
long_description = self.get_long_description()
211216
if long_description:
212217
file.write(f"\n{long_description}")
@@ -284,3 +289,33 @@ def _distribution_fullname(name: str, version: str) -> str:
284289
canonicalize_name(name).replace('-', '_'),
285290
canonicalize_version(version, strip_trailing_zero=False),
286291
)
292+
293+
294+
_POSSIBLE_DYNAMIC_FIELDS = {
295+
# Core Metadata Field x related Distribution attribute
296+
"author": "author",
297+
"author-email": "author_email",
298+
"classifier": "classifiers",
299+
"description": "long_description",
300+
"description-content-type": "long_description_content_type",
301+
"download-url": "download_url",
302+
"home-page": "url",
303+
"keywords": "keywords",
304+
"license": "license",
305+
# "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
306+
"maintainer": "maintainer",
307+
"maintainer-email": "maintainer_email",
308+
"obsoletes": "obsoletes",
309+
# "obsoletes-dist": "obsoletes_dist", # NOT USED
310+
"platform": "platforms",
311+
"project-url": "project_urls",
312+
"provides": "provides",
313+
# "provides-dist": "provides_dist", # NOT USED
314+
"provides-extra": "extras_require",
315+
"requires": "requires",
316+
"requires-dist": "install_requires",
317+
# "requires-external": "requires_external", # NOT USED
318+
"requires-python": "python_requires",
319+
"summary": "description",
320+
# "supported-platform": "supported_platforms", # NOT USED
321+
}

setuptools/_static.py

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from functools import wraps
2+
from typing import Any, TypeVar
3+
4+
import packaging.specifiers
5+
6+
from .warnings import SetuptoolsDeprecationWarning
7+
8+
9+
class Static:
10+
"""
11+
Wrapper for built-in object types that are allow setuptools to identify
12+
static core metadata (in opposition to ``Dynamic``, as defined :pep:`643`).
13+
14+
The trick is to mark values with :class:`Static` when they come from
15+
``pyproject.toml`` or ``setup.cfg``, so if any plugin overwrite the value
16+
with a built-in, setuptools will be able to recognise the change.
17+
18+
We inherit from built-in classes, so that we don't need to change the existing
19+
code base to deal with the new types.
20+
We also should strive for immutability objects to avoid changes after the
21+
initial parsing.
22+
"""
23+
24+
_mutated_: bool = False # TODO: Remove after deprecation warning is solved
25+
26+
27+
def _prevent_modification(target: type, method: str, copying: str) -> None:
28+
"""
29+
Because setuptools is very flexible we cannot fully prevent
30+
plugins and user customisations from modifying static values that were
31+
parsed from config files.
32+
But we can attempt to block "in-place" mutations and identify when they
33+
were done.
34+
"""
35+
fn = getattr(target, method, None)
36+
if fn is None:
37+
return
38+
39+
@wraps(fn)
40+
def _replacement(self: Static, *args, **kwargs):
41+
# TODO: After deprecation period raise NotImplementedError instead of warning
42+
# which obviated the existence and checks of the `_mutated_` attribute.
43+
self._mutated_ = True
44+
SetuptoolsDeprecationWarning.emit(
45+
"Direct modification of value will be disallowed",
46+
f"""
47+
In an effort to implement PEP 643, direct/in-place changes of static values
48+
that come from configuration files are deprecated.
49+
If you need to modify this value, please first create a copy with {copying}
50+
and make sure conform to all relevant standards when overriding setuptools
51+
functionality (https://packaging.python.org/en/latest/specifications/).
52+
""",
53+
due_date=(2025, 10, 10), # Initially introduced in 2024-09-06
54+
)
55+
return fn(self, *args, **kwargs)
56+
57+
_replacement.__doc__ = "" # otherwise doctest may fail.
58+
setattr(target, method, _replacement)
59+
60+
61+
class Str(str, Static):
62+
pass
63+
64+
65+
class Tuple(tuple, Static):
66+
pass
67+
68+
69+
class List(list, Static):
70+
"""
71+
:meta private:
72+
>>> x = List([1, 2, 3])
73+
>>> is_static(x)
74+
True
75+
>>> x += [0] # doctest: +IGNORE_EXCEPTION_DETAIL
76+
Traceback (most recent call last):
77+
SetuptoolsDeprecationWarning: Direct modification ...
78+
>>> is_static(x) # no longer static after modification
79+
False
80+
>>> y = list(x)
81+
>>> y.clear()
82+
>>> y
83+
[]
84+
>>> y == x
85+
False
86+
>>> is_static(List(y))
87+
True
88+
"""
89+
90+
91+
# Make `List` immutable-ish
92+
# (certain places of setuptools/distutils issue a warn if we use tuple instead of list)
93+
for _method in (
94+
'__delitem__',
95+
'__iadd__',
96+
'__setitem__',
97+
'append',
98+
'clear',
99+
'extend',
100+
'insert',
101+
'remove',
102+
'reverse',
103+
'pop',
104+
):
105+
_prevent_modification(List, _method, "`list(value)`")
106+
107+
108+
class Dict(dict, Static):
109+
"""
110+
:meta private:
111+
>>> x = Dict({'a': 1, 'b': 2})
112+
>>> is_static(x)
113+
True
114+
>>> x['c'] = 0 # doctest: +IGNORE_EXCEPTION_DETAIL
115+
Traceback (most recent call last):
116+
SetuptoolsDeprecationWarning: Direct modification ...
117+
>>> x._mutated_
118+
True
119+
>>> is_static(x) # no longer static after modification
120+
False
121+
>>> y = dict(x)
122+
>>> y.popitem()
123+
('b', 2)
124+
>>> y == x
125+
False
126+
>>> is_static(Dict(y))
127+
True
128+
"""
129+
130+
131+
# Make `Dict` immutable-ish (we cannot inherit from types.MappingProxyType):
132+
for _method in (
133+
'__delitem__',
134+
'__ior__',
135+
'__setitem__',
136+
'clear',
137+
'pop',
138+
'popitem',
139+
'setdefault',
140+
'update',
141+
):
142+
_prevent_modification(Dict, _method, "`dict(value)`")
143+
144+
145+
class SpecifierSet(packaging.specifiers.SpecifierSet, Static):
146+
"""Not exactly a built-in type but useful for ``requires-python``"""
147+
148+
149+
T = TypeVar("T")
150+
151+
152+
def noop(value: T) -> T:
153+
"""
154+
>>> noop(42)
155+
42
156+
"""
157+
return value
158+
159+
160+
_CONVERSIONS = {str: Str, tuple: Tuple, list: List, dict: Dict}
161+
162+
163+
def attempt_conversion(value: T) -> T:
164+
"""
165+
>>> is_static(attempt_conversion("hello"))
166+
True
167+
>>> is_static(object())
168+
False
169+
"""
170+
return _CONVERSIONS.get(type(value), noop)(value) # type: ignore[call-overload]
171+
172+
173+
def is_static(value: Any) -> bool:
174+
"""
175+
>>> is_static(a := Dict({'a': 1}))
176+
True
177+
>>> is_static(dict(a))
178+
False
179+
>>> is_static(b := List([1, 2, 3]))
180+
True
181+
>>> is_static(list(b))
182+
False
183+
"""
184+
return isinstance(value, Static) and not value._mutated_
185+
186+
187+
EMPTY_LIST = List()
188+
EMPTY_DICT = Dict()

0 commit comments

Comments
 (0)