diff --git a/setuptools/_static.py b/setuptools/_static.py index 97536b898b..901e72c1e6 100644 --- a/setuptools/_static.py +++ b/setuptools/_static.py @@ -180,3 +180,7 @@ def is_static(value: Any) -> bool: 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/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index f2de080774..b9526433ea 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 ---