Repository: pypa/setuptools (python)

Issue:
[BUG] DID NOT WARN errors on two tests
### setuptools version

main

### Python version

3.13.2

### OS

macOS

### Additional environment information

_No response_

### Description

Today I tried running the Setuptools tests and I'm getting two errors, seemingly due to warnings emitted as errors:

```
py: commands[0]> pytest -p no:cov --lf
=============================================================== test session starts ===============================================================
platform darwin -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
cachedir: .tox/py/.pytest_cache
rootdir: /Users/jaraco/code/pypa/setuptools
configfile: pytest.ini
plugins: enabler-3.3.0, mypy-0.10.3, checkdocs-2.13.0, jaraco.vcs-2.4.1, jaraco.test-5.5.1, home-0.6.0, ruff-0.4.1, subprocess-1.5.3, perf-0.15.0, timeout-2.3.1, xdist-3.6.1
12 workers [2 items]      
FF                                                                                                                                          [100%]
==================================================================== FAILURES =====================================================================
_____________________________________________________ TestMetadata.test_warn_dash_deprecation _____________________________________________________
[gw0] darwin -- Python 3.13.2 /Users/jaraco/code/pypa/setuptools/.tox/py/bin/python

self = <setuptools.tests.config.test_setupcfg.TestMetadata object at 0x103369130>
tmpdir = local('/private/var/folders/f2/2plv6q2n7l932m2x004jlw340000gn/T/pytest-of-jaraco/pytest-16/popen-gw0/test_warn_dash_deprecation0')

    def test_warn_dash_deprecation(self, tmpdir):
        # warn_dash_deprecation() is a method in setuptools.dist
        # remove this test and the method when no longer needed
        fake_env(
            tmpdir,
            '[metadata]\n'
            'author-email = test@test.com\n'
            'maintainer_email = foo@foo.com\n',
        )
        msg = "Usage of dash-separated 'author-email' will not be supported"
        with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
>           with get_dist(tmpdir) as dist:

setuptools/tests/config/test_setupcfg.py:434: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/opt/homebrew/Cellar/python@3.13/3.13.2/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:141: in __enter__
    return next(self.gen)
setuptools/tests/config/test_setupcfg.py:68: in get_dist
    parse and dist.parse_config_files()
.tox/py/lib/python3.13/site-packages/_virtualenv.py:22: in parse_config_files
    result = old_parse_config_files(self, *args, **kwargs)
setuptools/dist.py:646: in parse_config_files
    self._parse_config_files(filenames=inifiles)
setuptools/dist.py:493: in _parse_config_files
    opt = self.warn_dash_deprecation(opt, section)
setuptools/dist.py:541: in warn_dash_deprecation
    SetuptoolsDeprecationWarning.emit(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <class 'setuptools.warnings.SetuptoolsDeprecationWarning'>, summary = 'Invalid dash-separated options'
details = "\n                Usage of dash-separated 'author-email' will not be supported in future\n                versions. Please use the underscore name 'author_email' instead.\n                "
due_date = (2025, 3, 3), see_docs = 'userguide/declarative_config.html', see_url = None, stacklevel = 2, kwargs = {}
summary_ = 'Invalid dash-separated options'
details_ = "\n                Usage of dash-separated 'author-email' will not be supported in future\n                versions. Please use the underscore name 'author_email' instead.\n                "
docs_ref = 'userguide/declarative_config.html', docs_url = 'https://setuptools.pypa.io/en/latest/userguide/declarative_config.html'
due = datetime.date(2025, 3, 3)
text = 'Invalid dash-separated options\n!!\n\n        ***********************************************************************...nfig.html for details.\n        ********************************************************************************\n\n!!'

    @classmethod
    def emit(
        cls,
        summary: str | None = None,
        details: str | None = None,
        due_date: _DueDate | None = None,
        see_docs: str | None = None,
        see_url: str | None = None,
        stacklevel: int = 2,
        **kwargs,
    ) -> None:
        """Private: reserved for ``setuptools`` internal use only"""
        # Default values:
        summary_ = summary or getattr(cls, "_SUMMARY", None) or ""
        details_ = details or getattr(cls, "_DETAILS", None) or ""
        due_date = due_date or getattr(cls, "_DUE_DATE", None)
        docs_ref = see_docs or getattr(cls, "_SEE_DOCS", None)
        docs_url = docs_ref and f"https://setuptools.pypa.io/en/latest/{docs_ref}"
        see_url = see_url or getattr(cls, "_SEE_URL", None)
        due = date(*due_date) if due_date else None
    
        text = cls._format(summary_, details_, due, see_url or docs_url, kwargs)
        if due and due < date.today() and _should_enforce():
>           raise cls(text)
E           setuptools.warnings.SetuptoolsDeprecationWarning: Invalid dash-separated options
E           !!
E           
E                   ********************************************************************************
E                   Usage of dash-separated 'author-email' will not be supported in future
E                   versions. Please use the underscore name 'author_email' instead.
E           
E                   This deprecation is overdue, please update your project and remove deprecated
E                   calls to avoid build errors in the future.
E           
E                   See https://setuptools.pypa.io/en/latest/userguide/declarative_config.html for details.
E                   ********************************************************************************
E           
E           !!

setuptools/warnings.py:51: SetuptoolsDeprecationWarning

During handling of the above exception, another exception occurred:

self = <setuptools.tests.config.test_setupcfg.TestMetadata object at 0x103369130>
tmpdir = local('/private/var/folders/f2/2plv6q2n7l932m2x004jlw340000gn/T/pytest-of-jaraco/pytest-16/popen-gw0/test_warn_dash_deprecation0')

    def test_warn_dash_deprecation(self, tmpdir):
        # warn_dash_deprecation() is a method in setuptools.dist
        # remove this test and the method when no longer needed
        fake_env(
            tmpdir,
            '[metadata]\n'
            'author-email = test@test.com\n'
            'maintainer_email = foo@foo.com\n',
        )
        msg = "Usage of dash-separated 'author-email' will not be supported"
>       with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
E       Failed: DID NOT WARN. No warnings of type (<class 'setuptools.warnings.SetuptoolsDeprecationWarning'>,) were emitted.
E        Emitted warnings: [].

setuptools/tests/config/test_setupcfg.py:433: Failed
_____________________________________________________ TestMetadata.test_make_option_lowercase _____________________________________________________
[gw1] darwin -- Python 3.13.2 /Users/jaraco/code/pypa/setuptools/.tox/py/bin/python

self = <setuptools.tests.config.test_setupcfg.TestMetadata object at 0x105359250>
tmpdir = local('/private/var/folders/f2/2plv6q2n7l932m2x004jlw340000gn/T/pytest-of-jaraco/pytest-16/popen-gw1/test_make_option_lowercase0')

    def test_make_option_lowercase(self, tmpdir):
        # remove this test and the method make_option_lowercase() in setuptools.dist
        # when no longer needed
        fake_env(tmpdir, '[metadata]\nName = foo\ndescription = Some description\n')
        msg = "Usage of uppercase key 'Name' in 'metadata' will not be supported"
        with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
>           with get_dist(tmpdir) as dist:

setuptools/tests/config/test_setupcfg.py:446: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/opt/homebrew/Cellar/python@3.13/3.13.2/Frameworks/Python.framework/Versions/3.13/lib/python3.13/contextlib.py:141: in __enter__
    return next(self.gen)
setuptools/tests/config/test_setupcfg.py:68: in get_dist
    parse and dist.parse_config_files()
.tox/py/lib/python3.13/site-packages/_virtualenv.py:22: in parse_config_files
    result = old_parse_config_files(self, *args, **kwargs)
setuptools/dist.py:646: in parse_config_files
    self._parse_config_files(filenames=inifiles)
setuptools/dist.py:494: in _parse_config_files
    opt = self.make_option_lowercase(opt, section)
setuptools/dist.py:566: in make_option_lowercase
    SetuptoolsDeprecationWarning.emit(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <class 'setuptools.warnings.SetuptoolsDeprecationWarning'>, summary = 'Invalid uppercase configuration'
details = "\n            Usage of uppercase key 'Name' in 'metadata' will not be supported in\n            future versions. Please use lowercase 'name' instead.\n            "
due_date = (2025, 3, 3), see_docs = 'userguide/declarative_config.html', see_url = None, stacklevel = 2, kwargs = {}
summary_ = 'Invalid uppercase configuration'
details_ = "\n            Usage of uppercase key 'Name' in 'metadata' will not be supported in\n            future versions. Please use lowercase 'name' instead.\n            "
docs_ref = 'userguide/declarative_config.html', docs_url = 'https://setuptools.pypa.io/en/latest/userguide/declarative_config.html'
due = datetime.date(2025, 3, 3)
text = 'Invalid uppercase configuration\n!!\n\n        **********************************************************************...nfig.html for details.\n        ********************************************************************************\n\n!!'

    @classmethod
    def emit(
        cls,
        summary: str | None = None,
        details: str | None = None,
        due_date: _DueDate | None = None,
        see_docs: str | None = None,
        see_url: str | None = None,
        stacklevel: int = 2,
        **kwargs,
    ) -> None:
        """Private: reserved for ``setuptools`` internal use only"""
        # Default values:
        summary_ = summary or getattr(cls, "_SUMMARY", None) or ""
        details_ = details or getattr(cls, "_DETAILS", None) or ""
        due_date = due_date or getattr(cls, "_DUE_DATE", None)
        docs_ref = see_docs or getattr(cls, "_SEE_DOCS", None)
        docs_url = docs_ref and f"https://setuptools.pypa.io/en/latest/{docs_ref}"
        see_url = see_url or getattr(cls, "_SEE_URL", None)
        due = date(*due_date) if due_date else None
    
        text = cls._format(summary_, details_, due, see_url or docs_url, kwargs)
        if due and due < date.today() and _should_enforce():
>           raise cls(text)
E           setuptools.warnings.SetuptoolsDeprecationWarning: Invalid uppercase configuration
E           !!
E           
E                   ********************************************************************************
E                   Usage of uppercase key 'Name' in 'metadata' will not be supported in
E                   future versions. Please use lowercase 'name' instead.
E           
E                   This deprecation is overdue, please update your project and remove deprecated
E                   calls to avoid build errors in the future.
E           
E                   See https://setuptools.pypa.io/en/latest/userguide/declarative_config.html for details.
E                   ********************************************************************************
E           
E           !!

setuptools/warnings.py:51: SetuptoolsDeprecationWarning

During handling of the above exception, another exception occurred:

self = <setuptools.tests.config.test_setupcfg.TestMetadata object at 0x105359250>
tmpdir = local('/private/var/folders/f2/2plv6q2n7l932m2x004jlw340000gn/T/pytest-of-jaraco/pytest-16/popen-gw1/test_make_option_lowercase0')

    def test_make_option_lowercase(self, tmpdir):
        # remove this test and the method make_option_lowercase() in setuptools.dist
        # when no longer needed
        fake_env(tmpdir, '[metadata]\nName = foo\ndescription = Some description\n')
        msg = "Usage of uppercase key 'Name' in 'metadata' will not be supported"
>       with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
E       Failed: DID NOT WARN. No warnings of type (<class 'setuptools.warnings.SetuptoolsDeprecationWarning'>,) were emitted.
E        Emitted warnings: [].

setuptools/tests/config/test_setupcfg.py:445: Failed
============================================================= short test summary info =============================================================
FAILED setuptools/tests/config/test_setupcfg.py::TestMetadata::test_warn_dash_deprecation - Failed: DID NOT WARN. No warnings of type (<class 'setuptools.warnings.SetuptoolsDeprecationWarning'>,) were emitted.
FAILED setuptools/tests/config/test_setupcfg.py::TestMetadata::test_make_option_lowercase - Failed: DID NOT WARN. No warnings of type (<class 'setuptools.warnings.SetuptoolsDeprecationWarning'>,) were emitted.
================================================================ 2 failed in 2.52s ================================================================
py: exit 1 (2.75 seconds) /Users/jaraco/code/pypa/setuptools> pytest -p no:cov --lf pid=54523
  py: FAIL code 1 (6.05=setup[3.30]+cmd[2.75] seconds)
  evaluation failed :( (6.11 seconds)
```

These failures are happening on 8e39bc907, which passed CI, so there's something about my environment that's triggering the failures.

### Expected behavior

Tests should pass

### How to Reproduce

tox

### Output

```console

```


Pull request:
Disallow deprecated dash-separated and uppercase options in setup.cfg
<!-- 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

@jaraco, this is a possible approach for #4864, if we are feeling brave.
But it can break packages that have not addressed the warning. 

Closes #4864

### Pull Request Checklist
- [ ] Changes have tests
- [ ] 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/4870.removal.rst b/newsfragments/4870.removal.rst
new file mode 100644
index 000000000..5b713032d
--- /dev/null
+++ b/newsfragments/4870.removal.rst
@@ -0,0 +1,10 @@
+Setuptools no longer accepts options containing uppercase or dash characters in ``setup.cfg``.
+Please ensure to write the options in ``setup.cfg`` using the :wiki:`lower_snake_case <Snake_case>` convention
+(e.g. ``Name => name``, ``install-requires => install_requires``).
+This is a follow-up on deprecations introduced in
+`v54.1.0 <https://setuptools.pypa.io/en/latest/history.html#v54-1-0>`_ (see #1608) and
+`v54.1.1 <https://setuptools.pypa.io/en/latest/history.html#v54-1-1>`_ (see #2592).
+
+.. note::
+   This change *does not affect configurations in* ``pyproject.toml``
+   (which uses the :wiki:`lower-kebab-case <Letter_case#Kebab_case>` convention following the precedent set in :pep:`517`/:pep:`518`).
diff --git a/setuptools/dist.py b/setuptools/dist.py
index 024965126..8663077bb 100644
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import functools
 import io
 import itertools
 import numbers
@@ -27,6 +28,7 @@ from ._path import StrPath
 from ._reqs import _StrOrIter
 from .config import pyprojecttoml, setupcfg
 from .discovery import ConfigDiscovery
+from .errors import InvalidConfigError
 from .monkey import get_unpatched
 from .warnings import InformationOnly, SetuptoolsDeprecationWarning
 
@@ -490,8 +492,8 @@ class Distribution(_Distribution):
                         continue
 
                     val = parser.get(section, opt)
-                    opt = self.warn_dash_deprecation(opt, section)
-                    opt = self.make_option_lowercase(opt, section)
+                    opt = self._enforce_underscore(opt, section)
+                    opt = self._enforce_option_lowercase(opt, section)
                     opt_dict[opt] = (filename, val)
 
             # Make the ConfigParser forget everything (so we retain
@@ -516,64 +518,42 @@ class Distribution(_Distribution):
             except ValueError as e:
                 raise DistutilsOptionError(e) from e
 
-    def warn_dash_deprecation(self, opt: str, section: str) -> str:
-        if section in (
-            'options.extras_require',
-            'options.data_files',
-        ):
+    def _enforce_underscore(self, opt: str, section: str) -> str:
+        if "-" not in opt or self._skip_setupcfg_normalization(section):
             return opt
 
-        underscore_opt = opt.replace('-', '_')
-        commands = list(
-            itertools.chain(
-                distutils.command.__all__,
-                self._setuptools_commands(),
-            )
+        raise InvalidConfigError(
+            f"Invalid dash-separated key {opt!r} in {section!r} (setup.cfg), "
+            f"please use the underscore name {opt.replace('-', '_')!r} instead."
+            # Warning initially introduced in 3 Mar 2021
         )
-        if (
-            not section.startswith('options')
-            and section != 'metadata'
-            and section not in commands
-        ):
-            return underscore_opt
-
-        if '-' in opt:
-            SetuptoolsDeprecationWarning.emit(
-                "Invalid dash-separated options",
-                f"""
-                Usage of dash-separated {opt!r} will not be supported in future
-                versions. Please use the underscore name {underscore_opt!r} instead.
-                """,
-                see_docs="userguide/declarative_config.html",
-                due_date=(2025, 3, 3),
-                # Warning initially introduced in 3 Mar 2021
-            )
-        return underscore_opt
 
-    def _setuptools_commands(self):
-        try:
-            entry_points = metadata.distribution('setuptools').entry_points
-            return {ep.name for ep in entry_points}  # Avoid newer API for compatibility
-        except metadata.PackageNotFoundError:
-            # during bootstrapping, distribution doesn't exist
-            return []
-
-    def make_option_lowercase(self, opt: str, section: str) -> str:
-        if section != 'metadata' or opt.islower():
+    def _enforce_option_lowercase(self, opt: str, section: str) -> str:
+        if opt.islower() or self._skip_setupcfg_normalization(section):
             return opt
 
-        lowercase_opt = opt.lower()
-        SetuptoolsDeprecationWarning.emit(
-            "Invalid uppercase configuration",
-            f"""
-            Usage of uppercase key {opt!r} in {section!r} will not be supported in
-            future versions. Please use lowercase {lowercase_opt!r} instead.
-            """,
-            see_docs="userguide/declarative_config.html",
-            due_date=(2025, 3, 3),
+        raise InvalidConfigError(
+            f"Invalid uppercase key {opt!r} in {section!r} (setup.cfg), "
+            f"please use lowercase {opt.lower()!r} instead."
             # Warning initially introduced in 6 Mar 2021
         )
-        return lowercase_opt
+
+    def _skip_setupcfg_normalization(self, section: str) -> bool:
+        skip = (
+            'options.extras_require',
+            'options.data_files',
+            'options.entry_points',
+            'options.package_data',
+            'options.exclude_package_data',
+        )
+        return section in skip or not self._is_setuptools_section(section)
+
+    def _is_setuptools_section(self, section: str) -> bool:
+        return (
+            section == "metadata"
+            or section.startswith("options")
+            or section in _setuptools_commands()
+        )
 
     # FIXME: 'Distribution._set_command_options' is too complex (14)
     def _set_command_options(self, command_obj, option_dict=None):  # noqa: C901
@@ -999,6 +979,18 @@ class Distribution(_Distribution):
         super().run_command(command)
 
 
+@functools.cache
+def _setuptools_commands() -> set[str]:
+    try:
+        # Use older API for importlib.metadata compatibility
+        entry_points = metadata.distribution('setuptools').entry_points
+        eps: Iterable[str] = (ep.name for ep in entry_points)
+    except metadata.PackageNotFoundError:
+        # during bootstrapping, distribution doesn't exist
+        eps = []
+    return {*distutils.command.__all__, *eps}
+
+
 class DistDeprecationWarning(SetuptoolsDeprecationWarning):
     """Class for warning about deprecations in dist in
     setuptools. Not ignored by default, unlike DeprecationWarning."""


Test patch:
diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py
index d356d2b77..a199871ff 100644
--- a/setuptools/tests/config/test_setupcfg.py
+++ b/setuptools/tests/config/test_setupcfg.py
@@ -1,6 +1,7 @@
 import configparser
 import contextlib
 import inspect
+import re
 from pathlib import Path
 from unittest.mock import Mock, patch
 
@@ -9,6 +10,7 @@ from packaging.requirements import InvalidRequirement
 
 from setuptools.config.setupcfg import ConfigHandler, Target, read_configuration
 from setuptools.dist import Distribution, _Distribution
+from setuptools.errors import InvalidConfigError
 from setuptools.warnings import SetuptoolsDeprecationWarning
 
 from ..textwrap import DALS
@@ -420,36 +422,36 @@ class TestMetadata:
             with get_dist(tmpdir):
                 pass
 
-    @pytest.mark.xfail(reason="#4864")
-    def test_warn_dash_deprecation(self, tmpdir):
-        # warn_dash_deprecation() is a method in setuptools.dist
-        # remove this test and the method when no longer needed
-        fake_env(
-            tmpdir,
-            '[metadata]\n'
-            'author-email = test@test.com\n'
-            'maintainer_email = foo@foo.com\n',
-        )
-        msg = "Usage of dash-separated 'author-email' will not be supported"
-        with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
-            with get_dist(tmpdir) as dist:
-                metadata = dist.metadata
-
-        assert metadata.author_email == 'test@test.com'
-        assert metadata.maintainer_email == 'foo@foo.com'
-
-    @pytest.mark.xfail(reason="#4864")
-    def test_make_option_lowercase(self, tmpdir):
-        # remove this test and the method make_option_lowercase() in setuptools.dist
-        # when no longer needed
-        fake_env(tmpdir, '[metadata]\nName = foo\ndescription = Some description\n')
-        msg = "Usage of uppercase key 'Name' in 'metadata' will not be supported"
-        with pytest.warns(SetuptoolsDeprecationWarning, match=msg):
-            with get_dist(tmpdir) as dist:
-                metadata = dist.metadata
-
-        assert metadata.name == 'foo'
-        assert metadata.description == 'Some description'
+    @pytest.mark.parametrize(
+        ("error_msg", "config"),
+        [
+            (
+                "Invalid dash-separated key 'author-email' in 'metadata' (setup.cfg)",
+                DALS(
+                    """
+                    [metadata]
+                    author-email = test@test.com
+                    maintainer_email = foo@foo.com
+                    """
+                ),
+            ),
+            (
+                "Invalid uppercase key 'Name' in 'metadata' (setup.cfg)",
+                DALS(
+                    """
+                    [metadata]
+                    Name = foo
+                    description = Some description
+                    """
+                ),
+            ),
+        ],
+    )
+    def test_invalid_options_previously_deprecated(self, tmpdir, error_msg, config):
+        # this test and related methods can be removed when no longer needed
+        fake_env(tmpdir, config)
+        with pytest.raises(InvalidConfigError, match=re.escape(error_msg)):
+            get_dist(tmpdir).__enter__()
 
 
 class TestOptions: