Repository: pypa/setuptools

Issue:
Support cmdclass in setup.cfg
Having cmdclass defined in setup.cfg causes sdist,  or any other, command to fail because its value is not properly parsed into a dict.

How to replicate

setup.cfg example:
```
[metadata]
name = cmdclass example

[options]
packages = find:
cmdclass =
    sdist = build.CustomSdistCommand
```

setup.py:
```
import setuptools
setuptools.setup()
```

build.py
```
from setuptools.command.sdist import sdist

class CustomSdistCommand (sdist):
    def run(self):
        print('custom steps...')
        super(sdist, self).run()
```

error:
```
❯ python setup.py sdist
Traceback (most recent call last):
  File ".../setup.py", line 5, in <module>
    setuptools.setup()
  File ".../.venv/lib/python3.9/site-packages/setuptools/__init__.py", line 153, in setup
    return distutils.core.setup(**attrs)
  File ".../lib/python3.9/distutils/core.py", line 134, in setup
    ok = dist.parse_command_line()
  File ".../lib/python3.9/distutils/dist.py", line 483, in parse_command_line
    args = self._parse_command_opts(parser, args)
  File ".../.venv/lib/python3.9/site-packages/setuptools/dist.py", line 916, in _parse_command_opts
    nargs = _Distribution._parse_command_opts(self, parser, args)
  File ".../lib/python3.9/distutils/dist.py", line 539, in _parse_command_opts
    cmd_class = self.get_command_class(command)
  File ".../.venv/lib/python3.9/site-packages/setuptools/dist.py", line 750, in get_command_class
    return self.cmdclass[command]
TypeError: string indices must be integers
```

It happens because self.cmdclass evaluates to the string "sdist = build.CustomSdistCommand" but it should be { "sdist": <class 'build.CustomSdistCommand'> }


Environment details:
```
❯ python
Python 3.9.0 (v3.9.0:9cf6752276, Oct  5 2020, 11:29:23) 
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
```

PIP: 21.0.1
setuptools: 53.0.0


Possible solutions:
either change setup.cfg structure introducing options.cmdclass section or fix setuptools/dist.py Distribution._parse_config_files method so that it parses cmdclass value.

Full minimal example:
[example.zip](https://github.com/pypa/setuptools/files/5995374/example.zip)

Support cmdclass in setup.cfg
Having cmdclass defined in setup.cfg causes sdist,  or any other, command to fail because its value is not properly parsed into a dict.

How to replicate

setup.cfg example:
```
[metadata]
name = cmdclass example

[options]
packages = find:
cmdclass =
    sdist = build.CustomSdistCommand
```

setup.py:
```
import setuptools
setuptools.setup()
```

build.py
```
from setuptools.command.sdist import sdist

class CustomSdistCommand (sdist):
    def run(self):
        print('custom steps...')
        super(sdist, self).run()
```

error:
```
❯ python setup.py sdist
Traceback (most recent call last):
  File ".../setup.py", line 5, in <module>
    setuptools.setup()
  File ".../.venv/lib/python3.9/site-packages/setuptools/__init__.py", line 153, in setup
    return distutils.core.setup(**attrs)
  File ".../lib/python3.9/distutils/core.py", line 134, in setup
    ok = dist.parse_command_line()
  File ".../lib/python3.9/distutils/dist.py", line 483, in parse_command_line
    args = self._parse_command_opts(parser, args)
  File ".../.venv/lib/python3.9/site-packages/setuptools/dist.py", line 916, in _parse_command_opts
    nargs = _Distribution._parse_command_opts(self, parser, args)
  File ".../lib/python3.9/distutils/dist.py", line 539, in _parse_command_opts
    cmd_class = self.get_command_class(command)
  File ".../.venv/lib/python3.9/site-packages/setuptools/dist.py", line 750, in get_command_class
    return self.cmdclass[command]
TypeError: string indices must be integers
```

It happens because self.cmdclass evaluates to the string "sdist = build.CustomSdistCommand" but it should be { "sdist": <class 'build.CustomSdistCommand'> }


Environment details:
```
❯ python
Python 3.9.0 (v3.9.0:9cf6752276, Oct  5 2020, 11:29:23) 
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
```

PIP: 21.0.1
setuptools: 53.0.0


Possible solutions:
either change setup.cfg structure introducing options.cmdclass section or fix setuptools/dist.py Distribution._parse_config_files method so that it parses cmdclass value.

Full minimal example:
[example.zip](https://github.com/pypa/setuptools/files/5995374/example.zip)



Discussion:

Resolving patch:
diff --git a/setuptools/config.py b/setuptools/config.py
--- a/setuptools/config.py
+++ b/setuptools/config.py
@@ -574,6 +574,7 @@ def parsers(self):
         parse_list_semicolon = partial(self._parse_list, separator=';')
         parse_bool = self._parse_bool
         parse_dict = self._parse_dict
+        parse_cmdclass = self._parse_cmdclass
 
         return {
             'zip_safe': parse_bool,
@@ -594,6 +595,22 @@ def parsers(self):
             'entry_points': self._parse_file,
             'py_modules': parse_list,
             'python_requires': SpecifierSet,
+            'cmdclass': parse_cmdclass,
+        }
+
+    def _parse_cmdclass(self, value):
+        def resolve_class(qualified_class_name):
+            idx = qualified_class_name.rfind('.')
+            class_name = qualified_class_name[idx+1:]
+            pkg_name = qualified_class_name[:idx]
+
+            module = __import__(pkg_name)
+
+            return getattr(module, class_name)
+
+        return {
+            k: resolve_class(v)
+            for k, v in self._parse_dict(value).items()
         }
 
     def _parse_packages(self, value):


Test patch:
diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py
--- a/setuptools/tests/test_config.py
+++ b/setuptools/tests/test_config.py
@@ -1,3 +1,6 @@
+import types
+import sys
+
 import contextlib
 import configparser
 
@@ -7,6 +10,7 @@
 from mock import patch
 from setuptools.dist import Distribution, _Distribution
 from setuptools.config import ConfigHandler, read_configuration
+from distutils.core import Command
 from .textwrap import DALS
 
 
@@ -853,6 +857,26 @@ def test_python_requires_invalid(self, tmpdir):
             with get_dist(tmpdir) as dist:
                 dist.parse_config_files()
 
+    def test_cmdclass(self, tmpdir):
+        class CustomCmd(Command):
+            pass
+
+        m = types.ModuleType('custom_build', 'test package')
+
+        m.__dict__['CustomCmd'] = CustomCmd
+
+        sys.modules['custom_build'] = m
+
+        fake_env(
+            tmpdir,
+            '[options]\n'
+            'cmdclass =\n'
+            '    customcmd = custom_build.CustomCmd\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.cmdclass == {'customcmd': CustomCmd}
+
 
 saved_dist_init = _Distribution.__init__