Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions newsfragments/5120.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
An empty ``install_requires``, ``extras_require`` or ``project_urls`` passed to
``setup()`` is now reported as ``Dynamic`` in the core metadata. Previously an empty
value was indistinguishable from an omitted one, so a project that declared its
dependencies empty up front and filled them in later lost ``Dynamic: Requires-Dist``.
Omitting the value entirely is unchanged and still produces no ``Dynamic`` entry.
5 changes: 4 additions & 1 deletion setuptools/_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ def write_field(key, value):
_write_requirements(self, file)

for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
if (val := getattr(self, attr, None)) and not is_static(val):
# `is not None` rather than truthiness: a value the author set to an empty
# container or string is still a value they chose, and may be filled in
# later, so it belongs in Dynamic (#5120).
if (val := getattr(self, attr, None)) is not None and not is_static(val):
write_field('Dynamic', field)

long_description = self.get_long_description()
Expand Down
30 changes: 24 additions & 6 deletions setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,20 @@ class Distribution(_Distribution):
the distribution.
"""

# The container defaults below are :class:`_static.Static` so that an omitted
# value stays distinguishable from one the author set to an empty container.
# Only these three default to something falsy rather than ``None``, which is
# what the ``Dynamic`` loop in ``_core_metadata`` relies on to tell
# "absent" from "empty".
_DISTUTILS_UNSUPPORTED_METADATA: ClassVar[dict] = {
'long_description_content_type': lambda: None,
'project_urls': dict,
'project_urls': _static.Dict,
'provides_extras': dict, # behaves like an ordered set
'license_expression': lambda: None,
'license_file': lambda: None,
'license_files': lambda: None,
'install_requires': list,
'extras_require': dict,
'install_requires': _static.List,
'extras_require': _static.Dict,
}

# Used by build_py, editable_wheel and install_lib commands for legacy namespaces
Expand Down Expand Up @@ -392,8 +397,18 @@ def _finalize_requires(self):

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 {}
# Do not collapse with `or`: an empty Static default is falsy, and replacing
# it with a plain container would lose the fact that it was never set.
install_requires = getattr(self, "install_requires", None)
extras_require = getattr(self, "extras_require", None)
# An absent value falls back to a *static* empty container, matching the
# default in `_DISTUTILS_UNSUPPORTED_METADATA`; `_finalize_requires` copies
# whatever lands here onto `self.metadata`, so a plain one would overwrite
# that default and make an omitted value look explicitly empty.
if install_requires is None:
install_requires = _static.List()
if extras_require is None:
extras_require = _static.Dict()

# Preserve the "static"-ness of values parsed from config files
list_ = _static.List if _static.is_static(install_requires) else list
Expand Down Expand Up @@ -462,7 +477,10 @@ def _finalize_license_files(self) -> None:
else: # Patterns explicitly given by the user
files = self._expand_patterns(patterns, enforce_match=True)

self.metadata.license_files = list(unique_everseen(files))
found = list(unique_everseen(files))
# An empty result means no license file was found, which is not the same as
# the author declaring an empty list, so keep it static and out of Dynamic.
self.metadata.license_files = found or _static.List()

@classmethod
def _expand_patterns(
Expand Down
56 changes: 56 additions & 0 deletions setuptools/tests/test_core_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,62 @@ def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd):
metadata = _get_metadata(dist)
assert set(metadata.get_all("Dynamic")) == set(fields)

@pytest.mark.parametrize(
("attr", "empty", "field"),
[
("install_requires", [], "requires-dist"),
("extras_require", {}, "provides-extra"),
("project_urls", {}, "project-url"),
],
)
def test_explicitly_empty_container_is_dynamic(
self, attr, empty, field, tmpdir_cwd
):
# An empty value passed to ``setup()`` is a deliberate choice by the author,
# not an absent one, so the field can still be filled in later (#5120).
Path("pyproject.toml").write_text(
cleandoc(
"""
[project]
name = "package"
version = "0.0.1"
"""
),
encoding="utf-8",
)
dist = _makedist(**{attr: empty})
dist._finalize_requires()
assert field in {v.lower() for v in _get_metadata(dist).get_all("Dynamic", [])}

@pytest.mark.parametrize(
("attr", "field"),
[
("install_requires", "requires-dist"),
("extras_require", "provides-extra"),
("project_urls", "project-url"),
],
)
def test_omitted_container_is_not_dynamic(self, attr, field, tmpdir_cwd):
# Leaving the value out entirely must keep behaving as it always has.
Path("pyproject.toml").write_text(
cleandoc(
"""
[project]
name = "package"
version = "0.0.1"
"""
),
encoding="utf-8",
)
dist = _makedist()
dist._finalize_requires()
assert getattr(dist.metadata, attr) == (
[] if attr == "install_requires" else {}
)
assert field not in {
v.lower() for v in _get_metadata(dist).get_all("Dynamic", [])
}

@pytest.mark.parametrize(
"extra_toml",
[
Expand Down