Skip to content

Commit 22b3bf3

Browse files
committed
First draft implementation of '_static' in preparation for PEP 643
1 parent aad7d3d commit 22b3bf3

File tree

7 files changed

+166
-30
lines changed

7 files changed

+166
-30
lines changed

setuptools/_core_metadata.py

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

21-
from . import _normalization, _reqs
21+
from . import _normalization, _reqs, _static
2222
from .warnings import SetuptoolsDeprecationWarning
2323

2424
from distutils.util import rfc822_escape
@@ -27,7 +27,7 @@
2727
def get_metadata_version(self):
2828
mv = getattr(self, 'metadata_version', None)
2929
if mv is None:
30-
mv = Version('2.1')
30+
mv = Version('2.2')
3131
self.metadata_version = mv
3232
return mv
3333

@@ -207,6 +207,10 @@ def write_field(key, value):
207207
self._write_list(file, 'License-File', self.license_files or [])
208208
_write_requirements(self, file)
209209

210+
for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
211+
if hasattr(self, attr) and not isinstance(getattr(self, attr), _static.Static):
212+
write_field('Dynamic', field)
213+
210214
long_description = self.get_long_description()
211215
if long_description:
212216
file.write(f"\n{long_description}")
@@ -284,3 +288,32 @@ def _distribution_fullname(name: str, version: str) -> str:
284288
canonicalize_name(name).replace('-', '_'),
285289
canonicalize_version(version, strip_trailing_zero=False),
286290
)
291+
292+
293+
_POSSIBLE_DYNAMIC_FIELDS = {
294+
"author": "author",
295+
"author-email": "author_email",
296+
"classifier": "classifiers",
297+
"description": "long_description",
298+
"description-content-type": "long_description_content_type",
299+
"download-url": "download_url",
300+
"home-page": "url",
301+
"keywords": "keywords",
302+
"license": "license",
303+
# "license-file": "license_files", # PEP 639 allows backfilling without dynamic ??
304+
"maintainer": "maintainer",
305+
"maintainer-email": "maintainer_email",
306+
"obsoletes": "obsoletes",
307+
# "obsoletes-dist": "obsoletes_dist", # NOT USED
308+
"platform": "platforms",
309+
"project-url": "project_urls",
310+
"provides": "provides",
311+
# "provides-dist": "provides_dist", # NOT USED
312+
"provides-extra": "extras_require",
313+
"requires": "requires",
314+
"requires-dist": "install_requires",
315+
# "requires-external": "requires_external", # NOT USED
316+
"requires-python": "python_requires",
317+
"summary": "description",
318+
# "supported-platform": "supported_platforms", # NOT USED
319+
}

setuptools/_static.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from collections import abc
2+
from functools import singledispatch
3+
4+
import packaging.specifiers
5+
6+
7+
class Static:
8+
"""
9+
Wrapper for butil-in object types that are allow setuptools to identify
10+
static core metadata (in opposition to ``Dynamic``, as defined :pep:`643`).
11+
12+
The trick is to mark values with :class:`Static` when they come from
13+
``pyproject.toml`` or ``setup.cfg``, so if any plugin overwrite the value
14+
with a built-in, setuptools will be able to recognise the change.
15+
16+
We inherit from built-in classes, so that we don't need to change the existing
17+
code base to deal with the new types.
18+
We also prefer "immutable-ish" objects to avoid changes after the initial parsing.
19+
"""
20+
21+
22+
class Str(str, Static):
23+
pass
24+
25+
26+
class Tuple(tuple, Static):
27+
pass
28+
29+
30+
class Mapping(dict, Static):
31+
pass
32+
33+
34+
def _do_not_modify(*_, **__):
35+
raise NotImplementedError("Direct modification disallowed (statically defined)")
36+
37+
38+
# Make `Mapping` immutable-ish (we cannot inherit from types.MappingProxyType):
39+
for _method in (
40+
'__delitem__',
41+
'__ior__',
42+
'__setitem__',
43+
'clear',
44+
'pop',
45+
'popitem',
46+
'setdefault',
47+
'update',
48+
):
49+
setattr(Mapping, _method, _do_not_modify)
50+
51+
52+
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
59+
60+
61+
@convert.register
62+
def _(value: str) -> Str:
63+
return Str(value)
64+
65+
66+
@convert.register
67+
def _(value: str) -> Str:
68+
return Str(value)
69+
70+
71+
@convert.register
72+
def _(value: tuple) -> Tuple:
73+
return Tuple(value)
74+
75+
76+
@convert.register
77+
def _(value: list) -> Tuple:
78+
return Tuple(value)
79+
80+
81+
@convert.register
82+
def _(value: abc.Mapping) -> Mapping:
83+
return Mapping(value)

setuptools/config/expand.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from types import ModuleType, TracebackType
3535
from typing import TYPE_CHECKING, Any, Callable, TypeVar
3636

37+
from .. import _static
3738
from .._path import StrPath, same_path as _same_path
3839
from ..discovery import find_package_path
3940
from ..warnings import SetuptoolsWarning
@@ -181,7 +182,8 @@ def read_attr(
181182
spec = _find_spec(module_name, path)
182183

183184
try:
184-
return getattr(StaticModule(module_name, spec), attr_name)
185+
value = getattr(StaticModule(module_name, spec), attr_name)
186+
return _static.convert(value)
185187
except Exception:
186188
# fallback to evaluate module
187189
module = _load_spec(spec, module_name)

setuptools/config/setupcfg.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121

2222
from packaging.markers import default_environment as marker_env
2323
from packaging.requirements import InvalidRequirement, Requirement
24-
from packaging.specifiers import SpecifierSet
2524
from packaging.version import InvalidVersion, Version
2625

26+
from .. import _static
2727
from .._path import StrPath
2828
from ..errors import FileError, OptionError
2929
from ..warnings import SetuptoolsDeprecationWarning
@@ -309,7 +309,7 @@ def _parse_list(cls, value, separator=','):
309309
310310
:param value:
311311
:param separator: List items separator character.
312-
:rtype: list
312+
:rtype: tuple
313313
"""
314314
if isinstance(value, list): # _get_parser_compound case
315315
return value
@@ -367,7 +367,7 @@ def parser(value):
367367
f'Only strings are accepted for the {key} field, '
368368
'files are not accepted'
369369
)
370-
return value
370+
return _static.Str(value)
371371

372372
return parser
373373

@@ -387,15 +387,15 @@ def _parse_file(self, value, root_dir: StrPath | None):
387387
include_directive = 'file:'
388388

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

392392
if not value.startswith(include_directive):
393-
return value
393+
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 expand.read_files(filepaths, root_dir)
398+
return _static.Str(expand.read_files(filepaths, root_dir)) # Too optimistic?
399399

400400
def _parse_attr(self, value, package_dir, root_dir: StrPath):
401401
"""Represents value as a module attribute.
@@ -409,7 +409,7 @@ def _parse_attr(self, value, package_dir, root_dir: StrPath):
409409
"""
410410
attr_directive = 'attr:'
411411
if not value.startswith(attr_directive):
412-
return value
412+
return _static.Str(value)
413413

414414
attr_desc = value.replace(attr_directive, '')
415415

@@ -473,7 +473,7 @@ def parse_section(self, section_options) -> None:
473473
for name, (_, value) in section_options.items():
474474
with contextlib.suppress(KeyError):
475475
# Keep silent for a new option may appear anytime.
476-
self[name] = value
476+
self[name] = _static.convert(value)
477477

478478
def parse(self) -> None:
479479
"""Parses configuration file items from one
@@ -548,23 +548,23 @@ def __init__(
548548
@property
549549
def parsers(self):
550550
"""Metadata item name to parser function mapping."""
551-
parse_list = self._parse_list
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)
552553
parse_file = partial(self._parse_file, root_dir=self.root_dir)
553-
parse_dict = self._parse_dict
554554
exclude_files_parser = self._exclude_files_parser
555555

556556
return {
557-
'platforms': parse_list,
558-
'keywords': parse_list,
559-
'provides': parse_list,
560-
'obsoletes': parse_list,
561-
'classifiers': self._get_parser_compound(parse_file, parse_list),
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),
562562
'license': exclude_files_parser('license'),
563-
'license_files': parse_list,
563+
'license_files': parse_tuple_static,
564564
'description': parse_file,
565565
'long_description': parse_file,
566566
'version': self._parse_version,
567-
'project_urls': parse_dict,
567+
'project_urls': parse_dict_static,
568568
}
569569

570570
def _parse_version(self, value):
@@ -620,20 +620,19 @@ def _parse_requirements_list(self, label: str, value: str):
620620
_warn_accidental_env_marker_misconfig(label, value, parsed)
621621
# Filter it to only include lines that are not comments. `parse_list`
622622
# will have stripped each line and filtered out empties.
623-
return [line for line in parsed if not line.startswith("#")]
623+
return _static.Tuple(line for line in parsed if not line.startswith("#"))
624624

625625
@property
626626
def parsers(self):
627627
"""Metadata item name to parser function mapping."""
628628
parse_list = self._parse_list
629629
parse_bool = self._parse_bool
630-
parse_dict = self._parse_dict
631630
parse_cmdclass = self._parse_cmdclass
632631

633632
return {
634633
'zip_safe': parse_bool,
635634
'include_package_data': parse_bool,
636-
'package_dir': parse_dict,
635+
'package_dir': self._parse_dict,
637636
'scripts': parse_list,
638637
'eager_resources': parse_list,
639638
'dependency_links': parse_list,
@@ -650,7 +649,7 @@ def parsers(self):
650649
'packages': self._parse_packages,
651650
'entry_points': self._parse_file_in_root,
652651
'py_modules': parse_list,
653-
'python_requires': SpecifierSet,
652+
'python_requires': _static.SpeficierSet,
654653
'cmdclass': parse_cmdclass,
655654
}
656655

@@ -737,7 +736,7 @@ def parse_section_extras_require(self, section_options) -> None:
737736
lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),
738737
)
739738

740-
self['extras_require'] = parsed
739+
self['extras_require'] = _static.Mapping(parsed)
741740

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

setuptools/dist.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from . import (
2020
_entry_points,
2121
_reqs,
22+
_static,
2223
command as _, # noqa: F401 # imported for side-effects
2324
)
2425
from ._importlib import metadata
@@ -391,10 +392,15 @@ def _normalize_requires(self):
391392
"""Make sure requirement-related attributes exist and are normalized"""
392393
install_requires = getattr(self, "install_requires", None) or []
393394
extras_require = getattr(self, "extras_require", None) or {}
394-
self.install_requires = list(map(str, _reqs.parse(install_requires)))
395-
self.extras_require = {
396-
k: list(map(str, _reqs.parse(v or []))) for k, v in extras_require.items()
397-
}
395+
396+
# 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)))
399+
400+
mapp = _static.Mapping if isinstance(extras_require, _static.Static) else dict
401+
self.extras_require = mapp(
402+
(k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items()
403+
)
398404

399405
def _finalize_license_files(self) -> None:
400406
"""Compute names of all license files which should be included."""

setuptools/monkey.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ def patch_all():
8989
'distutils.command.build_ext'
9090
].Extension = setuptools.extension.Extension
9191

92+
if hasattr(distutils.dist, '_ensure_list'):
93+
from . import _static
94+
95+
ensure_list = distutils.dist._ensure_list
96+
97+
def _ensure_list_accept_static(value, fieldname):
98+
if isinstance(value, _static.Static):
99+
return value
100+
101+
return ensure_list(value, fieldname)
102+
103+
patch_func(_ensure_list_accept_static, distutils.dist, '_ensure_list')
104+
92105

93106
def _patch_distribution_metadata():
94107
from . import _core_metadata

setuptools/tests/test_egg_info.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ def test_provides_extra(self, tmpdir_cwd, env):
517517
with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp:
518518
pkg_info_lines = fp.read().split('\n')
519519
assert 'Provides-Extra: foobar' in pkg_info_lines
520-
assert 'Metadata-Version: 2.1' in pkg_info_lines
520+
assert 'Metadata-Version: 2.2' in pkg_info_lines
521521

522522
def test_doesnt_provides_extra(self, tmpdir_cwd, env):
523523
self._setup_script_with_requires(

0 commit comments

Comments
 (0)