Browse Source

Intermediate changes
commit_hash:e49cc600846762b0cef5d379812c52e8cb8f52f1

robot-piglet 1 month ago
parent
commit
e083472475

+ 2 - 2
contrib/python/setuptools/py3/.dist-info/METADATA

@@ -1,6 +1,6 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.2
 Name: setuptools
-Version: 75.7.0
+Version: 75.8.0
 Summary: Easily download, build, install, upgrade, and uninstall Python packages
 Author-email: Python Packaging Authority <distutils-sig@python.org>
 Project-URL: Source, https://github.com/pypa/setuptools

+ 36 - 1
contrib/python/setuptools/py3/setuptools/_core_metadata.py

@@ -19,6 +19,7 @@ from packaging.utils import canonicalize_name, canonicalize_version
 from packaging.version import Version
 
 from . import _normalization, _reqs
+from ._static import is_static
 from .warnings import SetuptoolsDeprecationWarning
 
 from distutils.util import rfc822_escape
@@ -27,7 +28,7 @@ from distutils.util import rfc822_escape
 def get_metadata_version(self):
     mv = getattr(self, 'metadata_version', None)
     if mv is None:
-        mv = Version('2.1')
+        mv = Version('2.2')
         self.metadata_version = mv
     return mv
 
@@ -207,6 +208,10 @@ def write_pkg_file(self, file):  # noqa: C901  # is too complex (14)  # FIXME
     self._write_list(file, 'License-File', self.license_files or [])
     _write_requirements(self, file)
 
+    for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
+        if (val := getattr(self, attr, None)) and not is_static(val):
+            write_field('Dynamic', field)
+
     long_description = self.get_long_description()
     if long_description:
         file.write(f"\n{long_description}")
@@ -284,3 +289,33 @@ def _distribution_fullname(name: str, version: str) -> str:
         canonicalize_name(name).replace('-', '_'),
         canonicalize_version(version, strip_trailing_zero=False),
     )
+
+
+_POSSIBLE_DYNAMIC_FIELDS = {
+    # Core Metadata Field x related Distribution attribute
+    "author": "author",
+    "author-email": "author_email",
+    "classifier": "classifiers",
+    "description": "long_description",
+    "description-content-type": "long_description_content_type",
+    "download-url": "download_url",
+    "home-page": "url",
+    "keywords": "keywords",
+    "license": "license",
+    # "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ??
+    "maintainer": "maintainer",
+    "maintainer-email": "maintainer_email",
+    "obsoletes": "obsoletes",
+    # "obsoletes-dist": "obsoletes_dist",  # NOT USED
+    "platform": "platforms",
+    "project-url": "project_urls",
+    "provides": "provides",
+    # "provides-dist": "provides_dist",  # NOT USED
+    "provides-extra": "extras_require",
+    "requires": "requires",
+    "requires-dist": "install_requires",
+    # "requires-external": "requires_external",  # NOT USED
+    "requires-python": "python_requires",
+    "summary": "description",
+    # "supported-platform": "supported_platforms",  # NOT USED
+}

+ 188 - 0
contrib/python/setuptools/py3/setuptools/_static.py

@@ -0,0 +1,188 @@
+from functools import wraps
+from typing import TypeVar
+
+import packaging.specifiers
+
+from .warnings import SetuptoolsDeprecationWarning
+
+
+class Static:
+    """
+    Wrapper for built-in object types that are allow setuptools to identify
+    static core metadata (in opposition to ``Dynamic``, as defined :pep:`643`).
+
+    The trick is to mark values with :class:`Static` when they come from
+    ``pyproject.toml`` or ``setup.cfg``, so if any plugin overwrite the value
+    with a built-in, setuptools will be able to recognise the change.
+
+    We inherit from built-in classes, so that we don't need to change the existing
+    code base to deal with the new types.
+    We also should strive for immutability objects to avoid changes after the
+    initial parsing.
+    """
+
+    _mutated_: bool = False  # TODO: Remove after deprecation warning is solved
+
+
+def _prevent_modification(target: type, method: str, copying: str) -> None:
+    """
+    Because setuptools is very flexible we cannot fully prevent
+    plugins and user customisations from modifying static values that were
+    parsed from config files.
+    But we can attempt to block "in-place" mutations and identify when they
+    were done.
+    """
+    fn = getattr(target, method, None)
+    if fn is None:
+        return
+
+    @wraps(fn)
+    def _replacement(self: Static, *args, **kwargs):
+        # TODO: After deprecation period raise NotImplementedError instead of warning
+        #       which obviated the existence and checks of the `_mutated_` attribute.
+        self._mutated_ = True
+        SetuptoolsDeprecationWarning.emit(
+            "Direct modification of value will be disallowed",
+            f"""
+            In an effort to implement PEP 643, direct/in-place changes of static values
+            that come from configuration files are deprecated.
+            If you need to modify this value, please first create a copy with {copying}
+            and make sure conform to all relevant standards when overriding setuptools
+            functionality (https://packaging.python.org/en/latest/specifications/).
+            """,
+            due_date=(2025, 10, 10),  # Initially introduced in 2024-09-06
+        )
+        return fn(self, *args, **kwargs)
+
+    _replacement.__doc__ = ""  # otherwise doctest may fail.
+    setattr(target, method, _replacement)
+
+
+class Str(str, Static):
+    pass
+
+
+class Tuple(tuple, Static):
+    pass
+
+
+class List(list, Static):
+    """
+    :meta private:
+    >>> x = List([1, 2, 3])
+    >>> is_static(x)
+    True
+    >>> x += [0]  # doctest: +IGNORE_EXCEPTION_DETAIL
+    Traceback (most recent call last):
+    SetuptoolsDeprecationWarning: Direct modification ...
+    >>> is_static(x)  # no longer static after modification
+    False
+    >>> y = list(x)
+    >>> y.clear()
+    >>> y
+    []
+    >>> y == x
+    False
+    >>> is_static(List(y))
+    True
+    """
+
+
+# Make `List` immutable-ish
+# (certain places of setuptools/distutils issue a warn if we use tuple instead of list)
+for _method in (
+    '__delitem__',
+    '__iadd__',
+    '__setitem__',
+    'append',
+    'clear',
+    'extend',
+    'insert',
+    'remove',
+    'reverse',
+    'pop',
+):
+    _prevent_modification(List, _method, "`list(value)`")
+
+
+class Dict(dict, Static):
+    """
+    :meta private:
+    >>> x = Dict({'a': 1, 'b': 2})
+    >>> is_static(x)
+    True
+    >>> x['c'] = 0  # doctest: +IGNORE_EXCEPTION_DETAIL
+    Traceback (most recent call last):
+    SetuptoolsDeprecationWarning: Direct modification ...
+    >>> x._mutated_
+    True
+    >>> is_static(x)  # no longer static after modification
+    False
+    >>> y = dict(x)
+    >>> y.popitem()
+    ('b', 2)
+    >>> y == x
+    False
+    >>> is_static(Dict(y))
+    True
+    """
+
+
+# Make `Dict` immutable-ish (we cannot inherit from types.MappingProxyType):
+for _method in (
+    '__delitem__',
+    '__ior__',
+    '__setitem__',
+    'clear',
+    'pop',
+    'popitem',
+    'setdefault',
+    'update',
+):
+    _prevent_modification(Dict, _method, "`dict(value)`")
+
+
+class SpecifierSet(packaging.specifiers.SpecifierSet, Static):
+    """Not exactly a built-in type but useful for ``requires-python``"""
+
+
+T = TypeVar("T")
+
+
+def noop(value: T) -> T:
+    """
+    >>> noop(42)
+    42
+    """
+    return value
+
+
+_CONVERSIONS = {str: Str, tuple: Tuple, list: List, dict: Dict}
+
+
+def attempt_conversion(value: T) -> T:
+    """
+    >>> is_static(attempt_conversion("hello"))
+    True
+    >>> is_static(object())
+    False
+    """
+    return _CONVERSIONS.get(type(value), noop)(value)  # type: ignore[call-overload]
+
+
+def is_static(value: object) -> bool:
+    """
+    >>> is_static(a := Dict({'a': 1}))
+    True
+    >>> is_static(dict(a))
+    False
+    >>> is_static(b := List([1, 2, 3]))
+    True
+    >>> is_static(list(b))
+    False
+    """
+    return isinstance(value, Static) and not value._mutated_
+
+
+EMPTY_LIST = List()
+EMPTY_DICT = Dict()

+ 51 - 25
contrib/python/setuptools/py3/setuptools/config/_apply_pyprojecttoml.py

@@ -20,6 +20,7 @@ from itertools import chain
 from types import MappingProxyType
 from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union
 
+from .. import _static
 from .._path import StrPath
 from ..errors import RemovedConfigError
 from ..extension import Extension
@@ -65,10 +66,11 @@ def apply(dist: Distribution, config: dict, filename: StrPath) -> Distribution:
 
 
 def _apply_project_table(dist: Distribution, config: dict, root_dir: StrPath):
-    project_table = config.get("project", {}).copy()
-    if not project_table:
+    orig_config = config.get("project", {})
+    if not orig_config:
         return  # short-circuit
 
+    project_table = {k: _static.attempt_conversion(v) for k, v in orig_config.items()}
     _handle_missing_dynamic(dist, project_table)
     _unify_entry_points(project_table)
 
@@ -98,7 +100,11 @@ def _apply_tool_table(dist: Distribution, config: dict, filename: StrPath):
             raise RemovedConfigError("\n".join([cleandoc(msg), suggestion]))
 
         norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)
-        _set_config(dist, norm_key, value)
+        corresp = TOOL_TABLE_CORRESPONDENCE.get(norm_key, norm_key)
+        if callable(corresp):
+            corresp(dist, value)
+        else:
+            _set_config(dist, corresp, value)
 
     _copy_command_options(config, dist, filename)
 
@@ -143,7 +149,7 @@ def _guess_content_type(file: str) -> str | None:
         return None
 
     if ext in _CONTENT_TYPES:
-        return _CONTENT_TYPES[ext]
+        return _static.Str(_CONTENT_TYPES[ext])
 
     valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items())
     msg = f"only the following file extensions are recognized: {valid}."
@@ -165,10 +171,11 @@ def _long_description(
         text = val.get("text") or expand.read_files(file, root_dir)
         ctype = val["content-type"]
 
-    _set_config(dist, "long_description", text)
+    # XXX: Is it completely safe to assume static?
+    _set_config(dist, "long_description", _static.Str(text))
 
     if ctype:
-        _set_config(dist, "long_description_content_type", ctype)
+        _set_config(dist, "long_description_content_type", _static.Str(ctype))
 
     if file:
         dist._referenced_files.add(file)
@@ -178,10 +185,12 @@ def _license(dist: Distribution, val: 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))
+        # 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", val["text"])
+        _set_config(dist, "license", _static.Str(val["text"]))
 
 
 def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str):
@@ -197,9 +206,9 @@ def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind
             email_field.append(str(addr))
 
     if field:
-        _set_config(dist, kind, ", ".join(field))
+        _set_config(dist, kind, _static.Str(", ".join(field)))
     if email_field:
-        _set_config(dist, f"{kind}_email", ", ".join(email_field))
+        _set_config(dist, f"{kind}_email", _static.Str(", ".join(email_field)))
 
 
 def _project_urls(dist: Distribution, val: dict, _root_dir: StrPath | None):
@@ -207,9 +216,7 @@ def _project_urls(dist: Distribution, val: dict, _root_dir: StrPath | None):
 
 
 def _python_requires(dist: Distribution, val: str, _root_dir: StrPath | None):
-    from packaging.specifiers import SpecifierSet
-
-    _set_config(dist, "python_requires", SpecifierSet(val))
+    _set_config(dist, "python_requires", _static.SpecifierSet(val))
 
 
 def _dependencies(dist: Distribution, val: list, _root_dir: StrPath | None):
@@ -237,9 +244,14 @@ def _noop(_dist: Distribution, val: _T) -> _T:
     return val
 
 
+def _identity(val: _T) -> _T:
+    return val
+
+
 def _unify_entry_points(project_table: dict):
     project = project_table
-    entry_points = project.pop("entry-points", project.pop("entry_points", {}))
+    given = project.pop("entry-points", project.pop("entry_points", {}))
+    entry_points = dict(given)  # Avoid problems with static
     renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"}
     for key, value in list(project.items()):  # eager to allow modifications
         norm_key = json_compatible_key(key)
@@ -333,6 +345,14 @@ def _get_previous_gui_scripts(dist: Distribution) -> list | None:
     return value.get("gui_scripts")
 
 
+def _set_static_list_metadata(attr: str, dist: Distribution, val: list) -> None:
+    """Apply distutils metadata validation but preserve "static" behaviour"""
+    meta = dist.metadata
+    setter, getter = getattr(meta, f"set_{attr}"), getattr(meta, f"get_{attr}")
+    setter(val)
+    setattr(meta, attr, _static.List(getter()))
+
+
 def _attrgetter(attr):
     """
     Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found
@@ -386,6 +406,12 @@ TOOL_TABLE_REMOVALS = {
         See https://packaging.python.org/en/latest/guides/packaging-namespace-packages/.
         """,
 }
+TOOL_TABLE_CORRESPONDENCE = {
+    # Fields with corresponding core metadata need to be marked as static:
+    "obsoletes": partial(_set_static_list_metadata, "obsoletes"),
+    "provides": partial(_set_static_list_metadata, "provides"),
+    "platforms": partial(_set_static_list_metadata, "platforms"),
+}
 
 SETUPTOOLS_PATCHES = {
     "long_description_content_type",
@@ -422,17 +448,17 @@ _PREVIOUSLY_DEFINED = {
 _RESET_PREVIOUSLY_DEFINED: dict = {
     # Fix improper setting: given in `setup.py`, but not listed in `dynamic`
     # dict: pyproject name => value to which reset
-    "license": {},
-    "authors": [],
-    "maintainers": [],
-    "keywords": [],
-    "classifiers": [],
-    "urls": {},
-    "entry-points": {},
-    "scripts": {},
-    "gui-scripts": {},
-    "dependencies": [],
-    "optional-dependencies": {},
+    "license": _static.EMPTY_DICT,
+    "authors": _static.EMPTY_LIST,
+    "maintainers": _static.EMPTY_LIST,
+    "keywords": _static.EMPTY_LIST,
+    "classifiers": _static.EMPTY_LIST,
+    "urls": _static.EMPTY_DICT,
+    "entry-points": _static.EMPTY_DICT,
+    "scripts": _static.EMPTY_DICT,
+    "gui-scripts": _static.EMPTY_DICT,
+    "dependencies": _static.EMPTY_LIST,
+    "optional-dependencies": _static.EMPTY_DICT,
 }
 
 

+ 4 - 1
contrib/python/setuptools/py3/setuptools/config/expand.py

@@ -34,6 +34,7 @@ from pathlib import Path
 from types import ModuleType, TracebackType
 from typing import TYPE_CHECKING, Any, Callable, TypeVar
 
+from .. import _static
 from .._path import StrPath, same_path as _same_path
 from ..discovery import find_package_path
 from ..warnings import SetuptoolsWarning
@@ -181,7 +182,9 @@ def read_attr(
     spec = _find_spec(module_name, path)
 
     try:
-        return getattr(StaticModule(module_name, spec), attr_name)
+        value = getattr(StaticModule(module_name, spec), attr_name)
+        # XXX: Is marking as static contents coming from modules too optimistic?
+        return _static.attempt_conversion(value)
     except Exception:
         # fallback to evaluate module
         module = _load_spec(spec, module_name)

+ 30 - 22
contrib/python/setuptools/py3/setuptools/config/setupcfg.py

@@ -21,9 +21,9 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar, cas
 
 from packaging.markers import default_environment as marker_env
 from packaging.requirements import InvalidRequirement, Requirement
-from packaging.specifiers import SpecifierSet
 from packaging.version import InvalidVersion, Version
 
+from .. import _static
 from .._path import StrPath
 from ..errors import FileError, OptionError
 from ..warnings import SetuptoolsDeprecationWarning
@@ -367,7 +367,7 @@ class ConfigHandler(Generic[Target]):
                     f'Only strings are accepted for the {key} field, '
                     'files are not accepted'
                 )
-            return value
+            return _static.Str(value)
 
         return parser
 
@@ -390,12 +390,13 @@ class ConfigHandler(Generic[Target]):
             return value
 
         if not value.startswith(include_directive):
-            return value
+            return _static.Str(value)
 
         spec = value[len(include_directive) :]
         filepaths = [path.strip() for path in spec.split(',')]
         self._referenced_files.update(filepaths)
-        return expand.read_files(filepaths, root_dir)
+        # XXX: Is marking as static contents coming from files too optimistic?
+        return _static.Str(expand.read_files(filepaths, root_dir))
 
     def _parse_attr(self, value, package_dir, root_dir: StrPath):
         """Represents value as a module attribute.
@@ -409,7 +410,7 @@ class ConfigHandler(Generic[Target]):
         """
         attr_directive = 'attr:'
         if not value.startswith(attr_directive):
-            return value
+            return _static.Str(value)
 
         attr_desc = value.replace(attr_directive, '')
 
@@ -548,23 +549,29 @@ class ConfigMetadataHandler(ConfigHandler["DistributionMetadata"]):
     @property
     def parsers(self):
         """Metadata item name to parser function mapping."""
-        parse_list = self._parse_list
+        parse_list_static = self._get_parser_compound(self._parse_list, _static.List)
+        parse_dict_static = self._get_parser_compound(self._parse_dict, _static.Dict)
         parse_file = partial(self._parse_file, root_dir=self.root_dir)
-        parse_dict = self._parse_dict
         exclude_files_parser = self._exclude_files_parser
 
         return {
-            'platforms': parse_list,
-            'keywords': parse_list,
-            'provides': parse_list,
-            'obsoletes': parse_list,
-            'classifiers': self._get_parser_compound(parse_file, parse_list),
+            'author': _static.Str,
+            'author_email': _static.Str,
+            'maintainer': _static.Str,
+            'maintainer_email': _static.Str,
+            'platforms': parse_list_static,
+            'keywords': parse_list_static,
+            'provides': parse_list_static,
+            'obsoletes': parse_list_static,
+            'classifiers': self._get_parser_compound(parse_file, parse_list_static),
             'license': exclude_files_parser('license'),
-            'license_files': parse_list,
+            'license_files': parse_list_static,
             'description': parse_file,
             'long_description': parse_file,
-            'version': self._parse_version,
-            'project_urls': parse_dict,
+            'long_description_content_type': _static.Str,
+            'version': self._parse_version,  # Cannot be marked as dynamic
+            'url': _static.Str,
+            'project_urls': parse_dict_static,
         }
 
     def _parse_version(self, value):
@@ -620,20 +627,20 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
         _warn_accidental_env_marker_misconfig(label, value, parsed)
         # Filter it to only include lines that are not comments. `parse_list`
         # will have stripped each line and filtered out empties.
-        return [line for line in parsed if not line.startswith("#")]
+        return _static.List(line for line in parsed if not line.startswith("#"))
+        # ^-- Use `_static.List` to mark a non-`Dynamic` Core Metadata
 
     @property
     def parsers(self):
         """Metadata item name to parser function mapping."""
         parse_list = self._parse_list
         parse_bool = self._parse_bool
-        parse_dict = self._parse_dict
         parse_cmdclass = self._parse_cmdclass
 
         return {
             'zip_safe': parse_bool,
             'include_package_data': parse_bool,
-            'package_dir': parse_dict,
+            'package_dir': self._parse_dict,
             'scripts': parse_list,
             'eager_resources': parse_list,
             'dependency_links': parse_list,
@@ -643,14 +650,14 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
                 "consider using implicit namespaces instead (PEP 420).",
                 # TODO: define due date, see setuptools.dist:check_nsp.
             ),
-            'install_requires': partial(
+            'install_requires': partial(  # Core Metadata
                 self._parse_requirements_list, "install_requires"
             ),
             'setup_requires': self._parse_list_semicolon,
             'packages': self._parse_packages,
             'entry_points': self._parse_file_in_root,
             'py_modules': parse_list,
-            'python_requires': SpecifierSet,
+            'python_requires': _static.SpecifierSet,  # Core Metadata
             'cmdclass': parse_cmdclass,
         }
 
@@ -727,7 +734,7 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
         """
         self['exclude_package_data'] = self._parse_package_data(section_options)
 
-    def parse_section_extras_require(self, section_options) -> None:
+    def parse_section_extras_require(self, section_options) -> None:  # Core Metadata
         """Parses `extras_require` configuration file section.
 
         :param dict section_options:
@@ -737,7 +744,8 @@ class ConfigOptionsHandler(ConfigHandler["Distribution"]):
             lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v),
         )
 
-        self['extras_require'] = parsed
+        self['extras_require'] = _static.Dict(parsed)
+        # ^-- Use `_static.Dict` to mark a non-`Dynamic` Core Metadata
 
     def parse_section_data_files(self, section_options) -> None:
         """Parses `data_files` configuration file section.

+ 10 - 4
contrib/python/setuptools/py3/setuptools/dist.py

@@ -19,6 +19,7 @@ from packaging.version import Version
 from . import (
     _entry_points,
     _reqs,
+    _static,
     command as _,  # noqa: F401 # imported for side-effects
 )
 from ._importlib import metadata
@@ -391,10 +392,15 @@ class Distribution(_Distribution):
         """Make sure requirement-related attributes exist and are normalized"""
         install_requires = getattr(self, "install_requires", None) or []
         extras_require = getattr(self, "extras_require", None) or {}
-        self.install_requires = list(map(str, _reqs.parse(install_requires)))
-        self.extras_require = {
-            k: list(map(str, _reqs.parse(v or []))) for k, v in extras_require.items()
-        }
+
+        # Preserve the "static"-ness of values parsed from config files
+        list_ = _static.List if _static.is_static(install_requires) else list
+        self.install_requires = list_(map(str, _reqs.parse(install_requires)))
+
+        dict_ = _static.Dict if _static.is_static(extras_require) else dict
+        self.extras_require = dict_(
+            (k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items()
+        )
 
     def _finalize_license_files(self) -> None:
         """Compute names of all license files which should be included."""

+ 2 - 1
contrib/python/setuptools/py3/ya.make

@@ -2,7 +2,7 @@
 
 PY3_LIBRARY()
 
-VERSION(75.7.0)
+VERSION(75.8.0)
 
 LICENSE(MIT)
 
@@ -93,6 +93,7 @@ PY_SRCS(
     setuptools/_path.py
     setuptools/_reqs.py
     setuptools/_shutil.py
+    setuptools/_static.py
     setuptools/archive_util.py
     setuptools/build_meta.py
     setuptools/command/__init__.py