Skip to content

Treat an explicitly empty value as set, not absent, when writing Dynamic - #5323

Open
darrenhuai wants to merge 1 commit into
pypa:mainfrom
darrenhuai:fix/5120-explicit-empty-is-dynamic
Open

darrenhuai wants to merge 1 commit into
pypa:mainfrom
darrenhuai:fix/5120-explicit-empty-is-dynamic

Conversation

@darrenhuai

Copy link
Copy Markdown

Summary of changes

Closes #5120

setup(install_requires=[]) produced no Dynamic: Requires-Dist, because the attributes that map to core metadata were tested for truthiness before being reported:

for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items():
    if (val := getattr(self, attr, None)) and not is_static(val):
        write_field('Dynamic', field)

An empty list is falsy, so "the author declared no dependencies yet" and "the author said nothing" collapse into the same branch. Declaring dependencies empty up front and filling them in later is the case dynamic = ["dependencies"] exists for, and it silently lost the field.

@abravalheri pointed at this line in the issue and flagged backwards compatibility as the hard part, so I measured it before changing anything. Of the 22 entries in _POSSIBLE_DYNAMIC_FIELDS, 19 already default to None when unset — for those, is not None is presence and nothing changes. Only three default to an empty container instead:

attribute default when unset
install_requires []
extras_require {}
project_urls {}

Those three are exactly why truthiness was standing in for presence. They now default to _static.List / _static.Dict, so an omitted value is still recognisable as omitted and still produces no Dynamic entry — while a container the author actually passed arrives as a plain list/dict and is reported.

Two places were discarding that staticness and had to stop:

  • _normalize_requires collapsed the value with or, which swaps a falsy static default for a plain container. _finalize_requires then copies the result onto self.metadata, so the static default was being overwritten before anything could read it.
  • _finalize_license_files always assigned a plain list. license_files is derived by setuptools rather than supplied by the author, so an empty expansion means no license file was found, not that an empty list was declared. It stays static and out of Dynamic, which is what test_static_config_has_no_dynamic already required.

Behaviour change worth calling out

A field explicitly set to an empty string — setup(author="") — is now reported as Dynamic: Author where it previously was not. That follows from the same rule rather than being a separate decision: the author set it, from setup.py, so it is dynamic. Values from pyproject.toml/setup.cfg are Static and unaffected either way. I'd rather surface this than have it discovered later, and I'm happy to restrict the change to the three container fields if you'd prefer the narrower blast radius.

I did not go for the DYNAMIC_PLACEHOLDER marker you floated. It would need a new public API and an opt-in from every affected project, whereas the staticness machinery already encodes "did this come from the author or from a default" — it just wasn't being applied to the defaults themselves.

Verification

Six new tests in TestPEP643, parametrised over all three container fields: explicitly-empty is Dynamic, omitted is not. The three explicitly-empty cases fail on main; the three omitted cases pass before and after and exist to pin the behaviour you were worried about.

End to end, using the reproduction from the issue:

setuptools setup.py Dynamic in PKG-INFO
main install_requires=[] (none) — the bug
this branch install_requires=[] Dynamic: requires-dist
this branch no install_requires (none) — unchanged
this branch install_requires=["requests"] Dynamic: requires-dist — unchanged

setuptools/tests is green on Windows / 3.13 (965 passed, 21 skipped, 16 xfailed), along with ruff check, ruff format, and the --doctest-modules pass over the changed modules. One unrelated pre-existing failure, test_windows_wrappers.py::TestCLI::test_symlink, needs symlink privileges this machine doesn't have; it fails identically on unmodified main.

Pull Request Checklist

Distribution attributes that map to core metadata were tested for
truthiness before being reported as Dynamic, so `setup(install_requires=[])`
was indistinguishable from leaving install_requires out. Declaring an empty
dependency list up front and filling it in later is exactly what
`dynamic = ["dependencies"]` is for, and it silently lost
`Dynamic: Requires-Dist`.

Nineteen of the twenty-two possible dynamic fields already default to None,
so presence is simply `is not None` for those. The three that don't -
install_requires, extras_require and project_urls - default to an empty
container, which is why truthiness was standing in for presence at all.
Those defaults are now static, so an omitted value is still recognisable as
one and keeps producing no Dynamic entry.

Two places had to stop discarding that staticness. _normalize_requires
collapsed the value with `or`, which swaps a falsy static default for a
plain container, and _finalize_requires then copies the result over
metadata. And license_files is derived by setuptools rather than given by
the author, so an empty expansion means no license file was found, not that
an empty list was declared; it stays static and out of Dynamic as before.

Closes pypa#5120
@mergify

mergify Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] setting install_requires=[] disables dynamic requires-dist

1 participant