Skip to content

Commit fad8675

Browse files
committed
Use _static.{List,Dict} and an attribute to track modifications instead of _static.{Tuple,Mapping} for better compatibility
1 parent 5124e8c commit fad8675

File tree

6 files changed

+168
-70
lines changed

6 files changed

+168
-70
lines changed

setuptools/_core_metadata.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from packaging.utils import canonicalize_name, canonicalize_version
1919
from packaging.version import Version
2020

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

2425
from distutils.util import rfc822_escape
@@ -208,7 +209,7 @@ def write_field(key, value):
208209
_write_requirements(self, file)
209210

210211
for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
211-
if hasattr(self, attr) and not isinstance(getattr(self, attr), _static.Static):
212+
if (val := getattr(self, attr, None)) and not is_static(val):
212213
write_field('Dynamic', field)
213214

214215
long_description = self.get_long_description()
@@ -291,6 +292,7 @@ def _distribution_fullname(name: str, version: str) -> str:
291292

292293

293294
_POSSIBLE_DYNAMIC_FIELDS = {
295+
# Core Metadata Field x related Distribution attribute
294296
"author": "author",
295297
"author-email": "author_email",
296298
"classifier": "classifiers",
@@ -300,7 +302,7 @@ def _distribution_fullname(name: str, version: str) -> str:
300302
"home-page": "url",
301303
"keywords": "keywords",
302304
"license": "license",
303-
# "license-file": "license_files", # PEP 639 allows backfilling without dynamic ??
305+
# "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
304306
"maintainer": "maintainer",
305307
"maintainer-email": "maintainer_email",
306308
"obsoletes": "obsoletes",

setuptools/_static.py

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
from collections import abc
2-
from functools import singledispatch
1+
from functools import wraps
2+
from typing import Any, TypeVar
33

44
import packaging.specifiers
55

6+
from .warnings import SetuptoolsDeprecationWarning
7+
68

79
class Static:
810
"""
9-
Wrapper for butil-in object types that are allow setuptools to identify
11+
Wrapper for built-in object types that are allow setuptools to identify
1012
static core metadata (in opposition to ``Dynamic``, as defined :pep:`643`).
1113
1214
The trick is to mark values with :class:`Static` when they come from
@@ -15,8 +17,43 @@ class Static:
1517
1618
We inherit from built-in classes, so that we don't need to change the existing
1719
code base to deal with the new types.
18-
We also prefer "immutable-ish" objects to avoid changes after the initial parsing.
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):
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.
1934
"""
35+
fn = getattr(target, method)
36+
37+
@wraps(fn)
38+
def _replacement(self: Static, *args, **kwargs):
39+
# TODO: After deprecation period raise NotImplementedError instead of warning
40+
# which obviated the existence and checks of the `_mutated_` attribute.
41+
self._mutated_ = True
42+
SetuptoolsDeprecationWarning.emit(
43+
"Direct modification of value will be disallowed",
44+
f"""
45+
In an effort to implement PEP 643, direct/in-place changes of static values
46+
that come from configuration files are deprecated.
47+
If you need to modify this value, please first create a copy with {copying}
48+
and make sure conform to all relevant standards when overriding setuptools
49+
functionality (https://packaging.python.org/en/latest/specifications/).
50+
""",
51+
due_date=(2025, 10, 10), # Initially introduced in 2024-09-06
52+
)
53+
return fn(self, *args, **kwargs)
54+
55+
_replacement.__doc__ = "" # otherwise doctest may fail.
56+
setattr(target, method, _replacement)
2057

2158

2259
class Str(str, Static):
@@ -27,15 +64,69 @@ class Tuple(tuple, Static):
2764
pass
2865

2966

30-
class Mapping(dict, Static):
31-
pass
67+
class List(list, Static):
68+
"""
69+
:meta private:
70+
>>> x = List([1, 2, 3])
71+
>>> is_static(x)
72+
True
73+
>>> x += [0] # doctest: +IGNORE_EXCEPTION_DETAIL
74+
Traceback (most recent call last):
75+
SetuptoolsDeprecationWarning: Direct modification ...
76+
>>> is_static(x) # no longer static after modification
77+
False
78+
>>> y = list(x)
79+
>>> y.clear()
80+
>>> y
81+
[]
82+
>>> y == x
83+
False
84+
>>> is_static(List(y))
85+
True
86+
"""
3287

3388

34-
def _do_not_modify(*_, **__):
35-
raise NotImplementedError("Direct modification disallowed (statically defined)")
89+
# Make `List` immutable-ish
90+
# (certain places of setuptools/distutils issue a warn if we use tuple instead of list)
91+
for _method in (
92+
'__delitem__',
93+
'__iadd__',
94+
'__setitem__',
95+
'append',
96+
'clear',
97+
'extend',
98+
'insert',
99+
'remove',
100+
'reverse',
101+
'pop',
102+
):
103+
_prevent_modification(List, _method, "`list(value)`")
36104

37105

38-
# Make `Mapping` immutable-ish (we cannot inherit from types.MappingProxyType):
106+
class Dict(dict, Static):
107+
"""
108+
:meta private:
109+
>>> x = Dict({'a': 1, 'b': 2})
110+
>>> is_static(x)
111+
True
112+
>>> x['c'] = 0 # doctest: +IGNORE_EXCEPTION_DETAIL
113+
Traceback (most recent call last):
114+
SetuptoolsDeprecationWarning: Direct modification ...
115+
>>> x._mutated_
116+
True
117+
>>> is_static(x) # no longer static after modification
118+
False
119+
>>> y = dict(x)
120+
>>> y.popitem()
121+
('b', 2)
122+
>>> y == x
123+
False
124+
>>> is_static(Dict(y))
125+
True
126+
"""
127+
128+
129+
# Make `Dict` immutable-ish (we cannot inherit from types.MappingProxyType):
39130
for _method in (
40131
'__delitem__',
41132
'__ior__',
@@ -46,38 +137,46 @@ def _do_not_modify(*_, **__):
46137
'setdefault',
47138
'update',
48139
):
49-
setattr(Mapping, _method, _do_not_modify)
140+
_prevent_modification(Dict, _method, "`dict(value)`")
50141

51142

52143
class SpeficierSet(packaging.specifiers.SpecifierSet, Static):
53-
"""Not exactly a builtin type but useful for ``requires-python``"""
54-
55-
56-
@singledispatch
57-
def convert(value):
58-
return value
144+
"""Not exactly a built-in type but useful for ``requires-python``"""
59145

60146

61-
@convert.register
62-
def _(value: str) -> Str:
63-
return Str(value)
147+
T = TypeVar("T")
64148

65149

66-
@convert.register
67-
def _(value: str) -> Str:
68-
return Str(value)
150+
def noop(value: T) -> T:
151+
"""
152+
>>> noop(42)
153+
42
154+
"""
155+
return value
69156

70157

71-
@convert.register
72-
def _(value: tuple) -> Tuple:
73-
return Tuple(value)
158+
_CONVERSIONS = {str: Str, tuple: Tuple, list: List, dict: Dict}
74159

75160

76-
@convert.register
77-
def _(value: list) -> Tuple:
78-
return Tuple(value)
161+
def attempt_conversion(value: T) -> T:
162+
"""
163+
>>> is_static(attempt_conversion("hello"))
164+
True
165+
>>> is_static(object())
166+
False
167+
"""
168+
return _CONVERSIONS.get(type(value), noop)(value) # type: ignore[call-overload]
79169

80170

81-
@convert.register
82-
def _(value: abc.Mapping) -> Mapping:
83-
return Mapping(value)
171+
def is_static(value: Any) -> bool:
172+
"""
173+
>>> is_static(a := Dict({'a': 1}))
174+
True
175+
>>> is_static(dict(a))
176+
False
177+
>>> is_static(b := List([1, 2, 3]))
178+
True
179+
>>> is_static(list(b))
180+
False
181+
"""
182+
return isinstance(value, Static) and not value._mutated_

setuptools/config/expand.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ def read_attr(
183183

184184
try:
185185
value = getattr(StaticModule(module_name, spec), attr_name)
186-
return _static.convert(value)
186+
# XXX: Is marking as static contents coming from modules too optimistic?
187+
return _static.attempt_conversion(value)
187188
except Exception:
188189
# fallback to evaluate module
189190
module = _load_spec(spec, module_name)

setuptools/config/setupcfg.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def _parse_list(cls, value, separator=','):
309309
310310
:param value:
311311
:param separator: List items separator character.
312-
:rtype: tuple
312+
:rtype: list
313313
"""
314314
if isinstance(value, list): # _get_parser_compound case
315315
return value
@@ -387,15 +387,16 @@ def _parse_file(self, value, root_dir: StrPath | None):
387387
include_directive = 'file:'
388388

389389
if not isinstance(value, str):
390-
return _static.convert(value)
390+
return value
391391

392392
if not value.startswith(include_directive):
393393
return _static.Str(value)
394394

395395
spec = value[len(include_directive) :]
396396
filepaths = [path.strip() for path in spec.split(',')]
397397
self._referenced_files.update(filepaths)
398-
return _static.Str(expand.read_files(filepaths, root_dir)) # Too optimistic?
398+
# XXX: Is marking as static contents coming from files too optimistic?
399+
return _static.Str(expand.read_files(filepaths, root_dir))
399400

400401
def _parse_attr(self, value, package_dir, root_dir: StrPath):
401402
"""Represents value as a module attribute.
@@ -473,7 +474,7 @@ def parse_section(self, section_options) -> None:
473474
for name, (_, value) in section_options.items():
474475
with contextlib.suppress(KeyError):
475476
# Keep silent for a new option may appear anytime.
476-
self[name] = _static.convert(value)
477+
self[name] = value
477478

478479
def parse(self) -> None:
479480
"""Parses configuration file items from one
@@ -548,22 +549,28 @@ def __init__(
548549
@property
549550
def parsers(self):
550551
"""Metadata item name to parser function mapping."""
551-
parse_tuple_static = self._get_parser_compound(self._parse_list, _static.Tuple)
552-
parse_dict_static = self._get_parser_compound(self._parse_dict, _static.Mapping)
552+
parse_list_static = self._get_parser_compound(self._parse_list, _static.List)
553+
parse_dict_static = self._get_parser_compound(self._parse_dict, _static.Dict)
553554
parse_file = partial(self._parse_file, root_dir=self.root_dir)
554555
exclude_files_parser = self._exclude_files_parser
555556

556557
return {
557-
'platforms': parse_tuple_static,
558-
'keywords': parse_tuple_static,
559-
'provides': parse_tuple_static,
560-
'obsoletes': parse_tuple_static,
561-
'classifiers': self._get_parser_compound(parse_file, parse_tuple_static),
558+
'author': _static.Str,
559+
'author_email': _static.Str,
560+
'maintainer': _static.Str,
561+
'maintainer_email': _static.Str,
562+
'platforms': parse_list_static,
563+
'keywords': parse_list_static,
564+
'provides': parse_list_static,
565+
'obsoletes': parse_list_static,
566+
'classifiers': self._get_parser_compound(parse_file, parse_list_static),
562567
'license': exclude_files_parser('license'),
563-
'license_files': parse_tuple_static,
568+
'license_files': parse_list_static,
564569
'description': parse_file,
565570
'long_description': parse_file,
566-
'version': self._parse_version,
571+
'long_description_content_type': _static.Str,
572+
'version': self._parse_version, # Cannot be marked as dynamic
573+
'url': _static.Str,
567574
'project_urls': parse_dict_static,
568575
}
569576

@@ -620,7 +627,8 @@ def _parse_requirements_list(self, label: str, value: str):
620627
_warn_accidental_env_marker_misconfig(label, value, parsed)
621628
# Filter it to only include lines that are not comments. `parse_list`
622629
# will have stripped each line and filtered out empties.
623-
return _static.Tuple(line for line in parsed if not line.startswith("#"))
630+
return _static.List(line for line in parsed if not line.startswith("#"))
631+
# ^-- Use `_static.List` to mark a non-`Dynamic` Core Metadata
624632

625633
@property
626634
def parsers(self):
@@ -642,14 +650,14 @@ def parsers(self):
642650
"consider using implicit namespaces instead (PEP 420).",
643651
# TODO: define due date, see setuptools.dist:check_nsp.
644652
),
645-
'install_requires': partial(
653+
'install_requires': partial( # Core Metadata
646654
self._parse_requirements_list, "install_requires"
647655
),
648656
'setup_requires': self._parse_list_semicolon,
649657
'packages': self._parse_packages,
650658
'entry_points': self._parse_file_in_root,
651659
'py_modules': parse_list,
652-
'python_requires': _static.SpeficierSet,
660+
'python_requires': _static.SpeficierSet, # Core Metadata
653661
'cmdclass': parse_cmdclass,
654662
}
655663

@@ -726,7 +734,7 @@ def parse_section_exclude_package_data(self, section_options) -> None:
726734
"""
727735
self['exclude_package_data'] = self._parse_package_data(section_options)
728736

729-
def parse_section_extras_require(self, section_options) -> None:
737+
def parse_section_extras_require(self, section_options) -> None: # Core Metadata
730738
"""Parses `extras_require` configuration file section.
731739
732740
:param dict section_options:
@@ -736,7 +744,8 @@ def parse_section_extras_require(self, section_options) -> None:
736744
lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),
737745
)
738746

739-
self['extras_require'] = _static.Mapping(parsed)
747+
self['extras_require'] = _static.Dict(parsed)
748+
# ^-- Use `_static.Dict` to mark a non-`Dynamic` Core Metadata
740749

741750
def parse_section_data_files(self, section_options) -> None:
742751
"""Parses `data_files` configuration file section.

setuptools/dist.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,11 @@ def _normalize_requires(self):
394394
extras_require = getattr(self, "extras_require", None) or {}
395395

396396
# Preserve the "static"-ness of values parsed from config files
397-
seq = _static.Tuple if isinstance(install_requires, _static.Static) else list
398-
self.install_requires = seq(map(str, _reqs.parse(install_requires)))
397+
list_ = _static.List if _static.is_static(install_requires) else list
398+
self.install_requires = list_(map(str, _reqs.parse(install_requires)))
399399

400-
mapp = _static.Mapping if isinstance(extras_require, _static.Static) else dict
401-
self.extras_require = mapp(
400+
dict_ = _static.Dict if _static.is_static(extras_require) else dict
401+
self.extras_require = dict_(
402402
(k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items()
403403
)
404404

0 commit comments

Comments
 (0)