Repository: pypa/setuptools
Pull request #4696 (2024-10-21 15:09:52)

Fix "residual" invalid dynamic behaviour of `optional-dependencies`
<!-- First time contributors: Take a moment to review https://setuptools.pypa.io/en/latest/development/developer-guide.html! -->
<!-- Remove sections if not applicable -->

## Summary of changes

This is reminiscent of https://github.com/pypa/setuptools/pull/3306.
It is another situation where PEP 621 does not allow mixing dynamic and static configurations.

Without explicitly listing `optional-dependencies` in `dynamic` users cannot rely on `setup.py` to specify `extras_require` -- in turn, when `optional-dependencies` *is present* in `dynamic`, then it cannot be listed as an static configuration.

Therefore, the only valid situation according to PEP 621, is that static `optional-dependencies` are honoured exactly as provided, without modifications (this means ignoring any `extras_require` previously set)..

<!-- Summary goes here -->

Closes https://github.com/pypa/setuptools/issues/3300

### Pull Request Checklist
- [x] Changes have tests
- [x] News fragment added in [`newsfragments/`].
  _(See [documentation][PR docs] for details)_


[`newsfragments/`]: https://github.com/pypa/setuptools/tree/master/newsfragments
[PR docs]:
https://setuptools.pypa.io/en/latest/development/developer-guide.html#making-a-pull-request



Patch:
diff --git a/newsfragments/4696.bugfix.rst b/newsfragments/4696.bugfix.rst
new file mode 100644
index 000000000..77ebf87c4
--- /dev/null
+++ b/newsfragments/4696.bugfix.rst
@@ -0,0 +1,4 @@
+Fix clashes for ``optional-dependencies`` in ``pyproject.toml`` and
+``extra_requires`` in ``setup.cfg/setup.py``.
+As per PEP 621, ``optional-dependencies`` has to be honoured and dynamic
+behaviour is not allowed.
diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py
index 23179f354..16fe753b5 100644
--- a/setuptools/config/_apply_pyprojecttoml.py
+++ b/setuptools/config/_apply_pyprojecttoml.py
@@ -217,8 +217,10 @@ def _dependencies(dist: Distribution, val: list, _root_dir):
 
 
 def _optional_dependencies(dist: Distribution, val: dict, _root_dir):
-    existing = getattr(dist, "extras_require", None) or {}
-    dist.extras_require = {**existing, **val}
+    if getattr(dist, "extras_require", None):
+        msg = "`extras_require` overwritten in `pyproject.toml` (optional-dependencies)"
+        SetuptoolsWarning.emit(msg)
+    dist.extras_require = val
 
 
 def _ext_modules(dist: Distribution, val: list[dict]) -> list[Extension]:


Test patch:
diff --git a/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py b/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py
index 37e5234a4..e42f28ffa 100644
--- a/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py
+++ b/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py
@@ -5,6 +5,7 @@ from jaraco import path
 
 from setuptools.config.pyprojecttoml import apply_configuration
 from setuptools.dist import Distribution
+from setuptools.warnings import SetuptoolsWarning
 
 
 def test_dynamic_dependencies(tmp_path):
@@ -77,23 +78,32 @@ def test_mixed_dynamic_optional_dependencies(tmp_path):
 
             [tool.setuptools.dynamic.optional-dependencies.images]
             file = ["requirements-images.txt"]
-
-            [build-system]
-            requires = ["setuptools", "wheel"]
-            build-backend = "setuptools.build_meta"
             """
         ),
     }
 
     path.build(files, prefix=tmp_path)
-
-    # Test that the mix-and-match doesn't currently validate.
     pyproject = tmp_path / "pyproject.toml"
     with pytest.raises(ValueError, match="project.optional-dependencies"):
         apply_configuration(Distribution(), pyproject)
 
-    # Explicitly disable the validation and try again, to see that the mix-and-match
-    # result would be correct.
-    dist = Distribution()
-    dist = apply_configuration(dist, pyproject, ignore_option_errors=True)
-    assert dist.extras_require == {"docs": ["sphinx"], "images": ["pillow~=42.0"]}
+
+def test_mixed_extras_require_optional_dependencies(tmp_path):
+    files = {
+        "pyproject.toml": cleandoc(
+            """
+            [project]
+            name = "myproj"
+            version = "1.0"
+            optional-dependencies.docs = ["sphinx"]
+            """
+        ),
+    }
+
+    path.build(files, prefix=tmp_path)
+    pyproject = tmp_path / "pyproject.toml"
+
+    with pytest.warns(SetuptoolsWarning, match=".extras_require. overwritten"):
+        dist = Distribution({"extras_require": {"hello": ["world"]}})
+        dist = apply_configuration(dist, pyproject)
+        assert dist.extras_require == {"docs": ["sphinx"]}