Repository: pypa/setuptools

Issue:
[FR] Improve setuptools tracebacks for invalid entry points
### setuptools version

65.5.1

### Python version

3.8.13

### OS

macOS 10.15.7

### Additional environment information

macOS 10.15.7
python 3.8.13 installed via pyenv
virtualenv has 
```console
> pip list
Package            Version
------------------ ---------
build              0.9.0
certifi            2022.9.24
charset-normalizer 2.1.1
idna               3.4
importlib-metadata 5.0.0
jaraco.classes     3.2.3
keyring            23.11.0
more-itertools     9.0.0
packaging          21.3
pep517             0.13.0
pip                22.3.1
pyparsing          3.0.9
pyvmomi            7.0.3
requests           2.28.1
setuptools         65.5.1
six                1.16.0
tomli              2.0.1
urllib3            1.26.12
zipp               3.10.0
```
(Top level is requests, pyvmomi, and a build dep of "build" - rest are dependancies)



### Description

Attempting to do a "simple" (what *I* thought was a simple build with "build" package.

My pyproject.toml:
```console

[build-system]
requires = [
    "setuptools",
    "build"
]

build-backend = "setuptools.build_meta"
```


My setup.cfg
```console

[metadata]
name = monitor-vsphere
version = attr: monitor-vsphere.main.__version__

author = Hunter Matthews
author_email = hunter.matthews@nih.gov
description = Query and compare VM's in both NHGRI's vsphere and Device42 systems. Report differences.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/NHGRI/monitor-vsphere
project_urls =
    Bug Tracker = https://github.com/NHGRI/monitor-vsphere/issues
classifiers =
    Programming Language :: Python :: 3
    Operating System :: OS Independent

[options]
python_requires = >=3.8
#include_package_data = True
package_dir =
    =src
packages = find:
install_requires =
    keyring
    requests
    pyvmomi
zip_safe = False      ## namespace pkgs are not zip safe - probably

[options.packages.find]
where = src

[options.extras_require]
dev = build

[options.entry_points]
console_scripts =
    monitor-vsphere = monitor-vsphere.main:main

```
-----------------------------------------------

I do NOT have a setup.py


My development tree after an unsuccessful build:
> tree
.

├── pyproject.toml
├── setup.cfg
└── src
    ├── monitor-vsphere
    │   ├── __init__.py
    │   ├── __pycache__
    │   │   └── __init__.cpython-38.pyc
    │   ├── auth.py
    │   ├── device42.py
    │   ├── dotdict.py
    │   ├── logs.py
    │   ├── main.py
    │   ├── virtualmachine.py
    │   └── vsphere.py
    └── monitor_vsphere.egg-info
        ├── PKG-INFO
        ├── dependency_links.txt
        └── not-zip-safe

### Expected behavior

Build a sdist and a wheel. 

### How to Reproduce

```console
python -m build
```

My apologies if this something very simple I've missed - the traceback is generic enough (a regex failure) that my google fu was weak on this one. 

I did try downgrading both setuptools  and importlib-metadata a couple of versions (guessing, based on the traceback) but no joy. 

Please let me know if other information is needed.


### Output

```console
> python -m build
* Creating venv isolated environment...
* Installing packages in isolated environment... (build, setuptools)
* Getting build dependencies for sdist...
running egg_info
writing src/monitor_vsphere.egg-info/PKG-INFO
writing dependency_links to src/monitor_vsphere.egg-info/dependency_links.txt
Traceback (most recent call last):
  File "/Users/matthewsht/projects/monitor-vsphere/.direnv/python-3.8.13/lib/python3.8/site-packages/pep517/in_process/_in_process.py", line 351, in <module>
    main()
  File "/Users/matthewsht/projects/monitor-vsphere/.direnv/python-3.8.13/lib/python3.8/site-packages/pep517/in_process/_in_process.py", line 333, in main
    json_out['return_val'] = hook(**hook_input['kwargs'])
  File "/Users/matthewsht/projects/monitor-vsphere/.direnv/python-3.8.13/lib/python3.8/site-packages/pep517/in_process/_in_process.py", line 285, in get_requires_for_build_sdist
    return hook(config_settings)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/build_meta.py", line 341, in get_requires_for_build_sdist
    return self._get_build_requires(config_settings, requirements=[])
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/build_meta.py", line 320, in _get_build_requires
    self.run_setup()
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/build_meta.py", line 335, in run_setup
    exec(code, locals())
  File "<string>", line 1, in <module>
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/__init__.py", line 87, in setup
    return distutils.core.setup(**attrs)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_distutils/core.py", line 185, in setup
    return run_commands(dist)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_distutils/core.py", line 201, in run_commands
    dist.run_commands()
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_distutils/dist.py", line 968, in run_commands
    self.run_command(cmd)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/dist.py", line 1217, in run_command
    super().run_command(command)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_distutils/dist.py", line 987, in run_command
    cmd_obj.run()
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/command/egg_info.py", line 301, in run
    writer(self, ep.name, os.path.join(self.egg_info, ep.name))
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/command/egg_info.py", line 741, in write_entries
    eps = _entry_points.load(cmd.distribution.entry_points)
  File "/Users/matthewsht/.local/pyenv/versions/3.8.13/lib/python3.8/functools.py", line 875, in wrapper
    return dispatch(args[0].__class__)(*args, **kw)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_entry_points.py", line 51, in load
    return validate(metadata.EntryPoints(groups))
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_entry_points.py", line 39, in validate
    consume(map(ensure_valid, ensure_unique(eps, key=by_group_and_name)))
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_vendor/more_itertools/recipes.py", line 139, in consume
    deque(iterator, maxlen=0)
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_entry_points.py", line 17, in ensure_valid
    ep.extras
  File "/private/var/folders/kg/ylmhpwmn3hb2bdlwl84t6c989ztfk2/T/build-env-vqmomun4/lib/python3.8/site-packages/setuptools/_vendor/importlib_metadata/__init__.py", line 211, in extras
    return list(re.finditer(r'\w+', match.group('extras') or ''))
AttributeError: 'NoneType' object has no attribute 'group'

ERROR Backend subprocess exited when trying to invoke get_requires_for_build_sdist
```



Discussion:
Hi @matthewsht,

I guess what is happening is that `monitor-vsphere` is not a valid Python importable package name.

The [entry-points specification](https://packaging.python.org/en/latest/specifications/entry-points/) says that:

> The object reference points to a Python object. It is either in the form `importable.module`, or `importable.module:object.attr`. Each of the parts delimited by dots and the colon is a valid Python identifier.

According to the [Python docs](https://docs.python.org/3/reference/lexical_analysis.html#identifiers) an identifier cannot contain a `-` (dash) character. We can easily verify that in a Python console:

```python
>>> "monitor-vsphere".isidentifier()
False
```



Resolving patch:
diff --git a/setuptools/_entry_points.py b/setuptools/_entry_points.py
--- a/setuptools/_entry_points.py
+++ b/setuptools/_entry_points.py
@@ -2,6 +2,7 @@
 import operator
 import itertools
 
+from .errors import OptionError
 from .extern.jaraco.text import yield_lines
 from .extern.jaraco.functools import pass_none
 from ._importlib import metadata
@@ -14,7 +15,14 @@ def ensure_valid(ep):
     Exercise one of the dynamic properties to trigger
     the pattern match.
     """
-    ep.extras
+    try:
+        ep.extras
+    except AttributeError as ex:
+        msg = (
+            f"Problems to parse {ep}.\nPlease ensure entry-point follows the spec: "
+            "https://packaging.python.org/en/latest/specifications/entry-points/"
+        )
+        raise OptionError(msg) from ex
 
 
 def load_group(value, group):


Test patch:
diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py
--- a/setuptools/tests/test_egg_info.py
+++ b/setuptools/tests/test_egg_info.py
@@ -6,12 +6,18 @@
 import stat
 import time
 from typing import List, Tuple
+from pathlib import Path
 
 import pytest
 from jaraco import path
 
+from setuptools import errors
 from setuptools.command.egg_info import (
-    egg_info, manifest_maker, EggInfoDeprecationWarning, get_pkg_info_revision,
+    EggInfoDeprecationWarning,
+    egg_info,
+    get_pkg_info_revision,
+    manifest_maker,
+    write_entries,
 )
 from setuptools.dist import Distribution
 
@@ -24,6 +30,28 @@ class Environment(str):
     pass
 
 
+@pytest.fixture
+def env():
+    with contexts.tempdir(prefix='setuptools-test.') as env_dir:
+        env = Environment(env_dir)
+        os.chmod(env_dir, stat.S_IRWXU)
+        subs = 'home', 'lib', 'scripts', 'data', 'egg-base'
+        env.paths = dict(
+            (dirname, os.path.join(env_dir, dirname))
+            for dirname in subs
+        )
+        list(map(os.mkdir, env.paths.values()))
+        path.build({
+            env.paths['home']: {
+                '.pydistutils.cfg': DALS("""
+                [egg_info]
+                egg-base = %(egg-base)s
+                """ % env.paths)
+            }
+        })
+        yield env
+
+
 class TestEggInfo:
 
     setup_script = DALS("""
@@ -51,27 +79,6 @@ def _extract_mv_version(pkg_info_lines: List[str]) -> Tuple[int, int]:
         version_str = pkg_info_lines[0].split(' ')[1]
         return tuple(map(int, version_str.split('.')[:2]))
 
-    @pytest.fixture
-    def env(self):
-        with contexts.tempdir(prefix='setuptools-test.') as env_dir:
-            env = Environment(env_dir)
-            os.chmod(env_dir, stat.S_IRWXU)
-            subs = 'home', 'lib', 'scripts', 'data', 'egg-base'
-            env.paths = dict(
-                (dirname, os.path.join(env_dir, dirname))
-                for dirname in subs
-            )
-            list(map(os.mkdir, env.paths.values()))
-            path.build({
-                env.paths['home']: {
-                    '.pydistutils.cfg': DALS("""
-                    [egg_info]
-                    egg-base = %(egg-base)s
-                    """ % env.paths)
-                }
-            })
-            yield env
-
     def test_egg_info_save_version_info_setup_empty(self, tmpdir_cwd, env):
         """
         When the egg_info section is empty or not present, running
@@ -1084,3 +1091,27 @@ def test_egg_info_tag_only_once(self, tmpdir_cwd, env):
 
     def test_get_pkg_info_revision_deprecated(self):
         pytest.warns(EggInfoDeprecationWarning, get_pkg_info_revision)
+
+
+class TestWriteEntries:
+
+    def test_invalid_entry_point(self, tmpdir_cwd, env):
+        dist = Distribution({"name": "foo", "version": "0.0.1"})
+        dist.entry_points = {"foo": "foo = invalid-identifier:foo"}
+        cmd = dist.get_command_obj("egg_info")
+        expected_msg = r"Problems to parse .*invalid-identifier.*"
+        with pytest.raises(errors.OptionError, match=expected_msg) as ex:
+            write_entries(cmd, "entry_points", "entry_points.txt")
+            assert "ensure entry-point follows the spec" in ex.value.args[0]
+
+    def test_valid_entry_point(self, tmpdir_cwd, env):
+        dist = Distribution({"name": "foo", "version": "0.0.1"})
+        dist.entry_points = {
+            "abc": "foo = bar:baz",
+            "def": ["faa = bor:boz"],
+        }
+        cmd = dist.get_command_obj("egg_info")
+        write_entries(cmd, "entry_points", "entry_points.txt")
+        content = Path("entry_points.txt").read_text(encoding="utf-8")
+        assert "[abc]\nfoo = bar:baz\n" in content
+        assert "[def]\nfaa = bor:boz\n" in content