diff --git a/setuptools/_core_metadata.py b/setuptools/_core_metadata.py index 642b80df31..850cc409f7 100644 --- a/setuptools/_core_metadata.py +++ b/setuptools/_core_metadata.py @@ -19,6 +19,7 @@ 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 @@ 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_field(key, value): 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 +} diff --git a/setuptools/_static.py b/setuptools/_static.py new file mode 100644 index 0000000000..4ddac2c08e --- /dev/null +++ b/setuptools/_static.py @@ -0,0 +1,188 @@ +from functools import wraps +from typing import Any, 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: Any) -> 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() diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py index c4bbcff730..331596bdd7 100644 --- a/setuptools/config/_apply_pyprojecttoml.py +++ b/setuptools/config/_apply_pyprojecttoml.py @@ -20,6 +20,7 @@ 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 @@ def _acessor(obj): 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 @@ def _acessor(obj): _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, } diff --git a/setuptools/config/expand.py b/setuptools/config/expand.py index ccb5d63cd2..531f965013 100644 --- a/setuptools/config/expand.py +++ b/setuptools/config/expand.py @@ -34,6 +34,7 @@ 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) diff --git a/setuptools/config/setupcfg.py b/setuptools/config/setupcfg.py index 4615815b6b..633aa9d45d 100644 --- a/setuptools/config/setupcfg.py +++ b/setuptools/config/setupcfg.py @@ -21,9 +21,9 @@ 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 @@ def parser(value): 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 @@ def _parse_file(self, value, root_dir: StrPath | None): 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 @@ def _parse_attr(self, value, package_dir, root_dir: StrPath): """ 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 @@ def __init__( @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 @@ def _parse_requirements_list(self, label: str, value: str): _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 @@ def parsers(self): "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 @@ def parse_section_exclude_package_data(self, section_options) -> None: """ 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 @@ def parse_section_extras_require(self, section_options) -> None: 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. diff --git a/setuptools/dist.py b/setuptools/dist.py index f878b2fa45..0249651267 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -19,6 +19,7 @@ from . import ( _entry_points, _reqs, + _static, command as _, # noqa: F401 # imported for side-effects ) from ._importlib import metadata @@ -391,10 +392,15 @@ def _normalize_requires(self): """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.""" diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index da43bb6a2b..20146b4a89 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -18,6 +18,7 @@ from packaging.metadata import Metadata import setuptools # noqa: F401 # ensure monkey patch to metadata +from setuptools._static import is_static from setuptools.command.egg_info import write_requirements from setuptools.config import expand, pyprojecttoml, setupcfg from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter @@ -480,6 +481,32 @@ def test_version(self, tmp_path, monkeypatch, capsys): assert "42.0" in captured.out +class TestStaticConfig: + def test_mark_static_fields(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + toml_config = """ + [project] + name = "test" + version = "42.0" + dependencies = ["hello"] + keywords = ["world"] + classifiers = ["private :: hello world"] + [tool.setuptools] + obsoletes = ["abcd"] + provides = ["abcd"] + platforms = ["abcd"] + """ + pyproject = Path(tmp_path, "pyproject.toml") + pyproject.write_text(cleandoc(toml_config), encoding="utf-8") + dist = pyprojecttoml.apply_configuration(Distribution({}), pyproject) + assert is_static(dist.install_requires) + assert is_static(dist.metadata.keywords) + assert is_static(dist.metadata.classifiers) + assert is_static(dist.metadata.obsoletes) + assert is_static(dist.metadata.provides) + assert is_static(dist.metadata.platforms) + + # --- Auxiliary Functions --- diff --git a/setuptools/tests/config/test_expand.py b/setuptools/tests/config/test_expand.py index fa9122b32c..c5710ec63d 100644 --- a/setuptools/tests/config/test_expand.py +++ b/setuptools/tests/config/test_expand.py @@ -4,6 +4,7 @@ import pytest +from setuptools._static import is_static from setuptools.config import expand from setuptools.discovery import find_package_path @@ -93,11 +94,15 @@ def test_read_attr(self, tmp_path, monkeypatch): with monkeypatch.context() as m: m.chdir(tmp_path) # Make sure it can read the attr statically without evaluating the module - assert expand.read_attr('pkg.sub.VERSION') == '0.1.1' + version = expand.read_attr('pkg.sub.VERSION') values = expand.read_attr('lib.mod.VALUES', {'lib': 'pkg/sub'}) + assert version == '0.1.1' + assert is_static(values) + assert values['a'] == 0 assert values['b'] == {42} + assert is_static(values) # Make sure the same APIs work outside cwd assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1' @@ -118,7 +123,28 @@ def test_read_annotated_attr(self, tmp_path, example): } write_files(files, tmp_path) # Make sure this attribute can be read statically - assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1' + version = expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) + assert version == '0.1.1' + assert is_static(version) + + @pytest.mark.parametrize( + "example", + [ + "VERSION = (lambda: '0.1.1')()\n", + "def fn(): return '0.1.1'\nVERSION = fn()\n", + "VERSION: str = (lambda: '0.1.1')()\n", + ], + ) + def test_read_dynamic_attr(self, tmp_path, monkeypatch, example): + files = { + "pkg/__init__.py": "", + "pkg/sub/__init__.py": example, + } + write_files(files, tmp_path) + monkeypatch.chdir(tmp_path) + version = expand.read_attr('pkg.sub.VERSION') + assert version == '0.1.1' + assert not is_static(version) def test_import_order(self, tmp_path): """ diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py index c34b9eb831..b1edb79b40 100644 --- a/setuptools/tests/test_core_metadata.py +++ b/setuptools/tests/test_core_metadata.py @@ -8,6 +8,7 @@ from email.message import EmailMessage, Message from email.parser import Parser from email.policy import EmailPolicy +from inspect import cleandoc from pathlib import Path from unittest.mock import Mock @@ -411,6 +412,91 @@ def test_equivalent_output(self, tmp_path, dist): _assert_roundtrip_message(pkg_info) +class TestPEP643: + STATIC_CONFIG = { + "setup.cfg": cleandoc( + """ + [metadata] + name = package + version = 0.0.1 + author = Foo Bar + author_email = foo@bar.net + long_description = Long + description + description = Short description + keywords = one, two + platforms = abcd + [options] + install_requires = requests + """ + ), + "pyproject.toml": cleandoc( + """ + [project] + name = "package" + version = "0.0.1" + authors = [ + {name = "Foo Bar", email = "foo@bar.net"} + ] + description = "Short description" + readme = {text = "Long\\ndescription", content-type = "text/plain"} + keywords = ["one", "two"] + dependencies = ["requests"] + [tool.setuptools] + provides = ["abcd"] + obsoletes = ["abcd"] + """ + ), + } + + @pytest.mark.parametrize("file", STATIC_CONFIG.keys()) + def test_static_config_has_no_dynamic(self, file, tmpdir_cwd): + Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8") + metadata = _get_metadata() + assert metadata.get_all("Dynamic") is None + assert metadata.get_all("dynamic") is None + + @pytest.mark.parametrize("file", STATIC_CONFIG.keys()) + @pytest.mark.parametrize( + "fields", + [ + # Single dynamic field + {"requires-python": ("python_requires", ">=3.12")}, + {"author-email": ("author_email", "snoopy@peanuts.com")}, + {"keywords": ("keywords", ["hello", "world"])}, + {"platform": ("platforms", ["abcd"])}, + # Multiple dynamic fields + { + "summary": ("description", "hello world"), + "description": ("long_description", "bla bla bla bla"), + "requires-dist": ("install_requires", ["hello-world"]), + }, + ], + ) + def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd): + # We start with a static config + Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8") + dist = _makedist() + + # ... but then we simulate the effects of a plugin modifying the distribution + for attr, value in fields.values(): + # `dist` and `dist.metadata` are complicated... + # Some attributes work when set on `dist`, others on `dist.metadata`... + # Here we set in both just in case (this also avoids calling `_finalize_*`) + setattr(dist, attr, value) + setattr(dist.metadata, attr, value) + + # Then we should be able to list the modified fields as Dynamic + metadata = _get_metadata(dist) + assert set(metadata.get_all("Dynamic")) == set(fields) + + +def _makedist(**attrs): + dist = Distribution(attrs) + dist.parse_config_files() + return dist + + def _assert_roundtrip_message(metadata: str) -> None: """Emulate the way wheel.bdist_wheel parses and regenerates the message, then ensures the metadata generated by setuptools is compatible. @@ -462,6 +548,9 @@ def _normalize_metadata(msg: Message) -> str: for extra in sorted(extras): msg["Provides-Extra"] = extra + # TODO: Handle lack of PEP 643 implementation in pypa/wheel? + del msg["Metadata-Version"] + return msg.as_string() @@ -479,6 +568,10 @@ def _get_pkginfo(dist: Distribution): return fp.getvalue() +def _get_metadata(dist: Distribution | None = None): + return message_from_string(_get_pkginfo(dist or _makedist())) + + def _valid_metadata(text: str) -> bool: metadata = Metadata.from_email(text, validate=True) # can raise exceptions return metadata is not None diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py index 9924f9cbbd..8233c9b884 100644 --- a/setuptools/tests/test_egg_info.py +++ b/setuptools/tests/test_egg_info.py @@ -517,7 +517,7 @@ def test_provides_extra(self, tmpdir_cwd, env): with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') assert 'Provides-Extra: foobar' in pkg_info_lines - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines def test_doesnt_provides_extra(self, tmpdir_cwd, env): self._setup_script_with_requires( @@ -1064,7 +1064,7 @@ def test_metadata_version(self, tmpdir_cwd, env): with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') # Update metadata version if changed - assert self._extract_mv_version(pkg_info_lines) == (2, 1) + assert self._extract_mv_version(pkg_info_lines) == (2, 2) def test_long_description_content_type(self, tmpdir_cwd, env): # Test that specifying a `long_description_content_type` keyword arg to @@ -1091,7 +1091,7 @@ def test_long_description_content_type(self, tmpdir_cwd, env): pkg_info_lines = fp.read().split('\n') expected_line = 'Description-Content-Type: text/markdown' assert expected_line in pkg_info_lines - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines def test_long_description(self, tmpdir_cwd, env): # Test that specifying `long_description` and `long_description_content_type` @@ -1110,7 +1110,7 @@ def test_long_description(self, tmpdir_cwd, env): egg_info_dir = os.path.join('.', 'foo.egg-info') with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines assert '' == pkg_info_lines[-1] # last line should be empty long_desc_lines = pkg_info_lines[pkg_info_lines.index('') :] assert 'This is a long description' in long_desc_lines