Repository: pypa/pip

Issue:
Installation error for package with un-normalized extra names in pip 24.1b1
I think I found an issue with `24.1b1` with a scenario that works fine on `24.0`.

Consider these requirements:

```txt
# requirements.txt
sqlalchemy==1.4.*
```

Create a virtualenv that uses pip 24.1b1:

```
VIRTUALENV_PIP=24.1b1 virtualenv .venv --python python3.8
source .venv/bin/activate
```

Install the requirements:

```
pip install -r requirements.txt
```

and it fails

```
Collecting sqlalchemy==1.4.* (from -r requirements.txt (line 1))
  Using cached SQLAlchemy-1.4.52.tar.gz (8.5 MB)
  Preparing metadata (setup.py) ... done
ERROR: Exception:
Traceback (most recent call last):
  File "/Users/me/repro/pip-24.1-sqlalchemy/.venv/lib/python3.8/site-packages/pip/_vendor/pkg_resources/__init__.py", line 2791, in requires
    deps.extend(dm[safe_extra(ext)])
KeyError: 'mariadb-connector'
```

---

Debug output:

```shell
pip install --no-cache-dir --debug -vvv -r requirements.txt > stdout.txt 2> stderr.txt
```

[stdout.txt](https://github.com/pypa/pip/files/15255880/stdout.txt)
[stderr.txt](https://github.com/pypa/pip/files/15255881/stderr.txt)


_Originally posted by @edgarrmondragon in https://github.com/pypa/pip/issues/12613#issuecomment-2100861268_



Discussion:

Patch:
diff --git a/news/12688.bugfix.rst b/news/12688.bugfix.rst
new file mode 100644
index 000000000..72f8766ba
--- /dev/null
+++ b/news/12688.bugfix.rst
@@ -0,0 +1,1 @@
+Accommodate for mismatches between different sources of truth for extra names, for packages generated by ``setuptools``.
diff --git a/news/12713.feature.rst b/news/12713.feature.rst
new file mode 100644
index 000000000..68a4de4a1
--- /dev/null
+++ b/news/12713.feature.rst
@@ -0,0 +1,1 @@
+Report informative messages about invalid requirements.
diff --git a/src/pip/_internal/metadata/pkg_resources.py b/src/pip/_internal/metadata/pkg_resources.py
index 235556781..4ea84f93a 100644
--- a/src/pip/_internal/metadata/pkg_resources.py
+++ b/src/pip/_internal/metadata/pkg_resources.py
@@ -11,7 +11,6 @@ from typing import (
     Mapping,
     NamedTuple,
     Optional,
-    cast,
 )
 
 from pip._vendor import pkg_resources
@@ -92,8 +91,7 @@ class Distribution(BaseDistribution):
     def _extra_mapping(self) -> Mapping[NormalizedName, str]:
         if self.__extra_mapping is None:
             self.__extra_mapping = {
-                canonicalize_name(extra): pkg_resources.safe_extra(cast(str, extra))
-                for extra in self.metadata.get_all("Provides-Extra", [])
+                canonicalize_name(extra): extra for extra in self._dist.extras
             }
 
         return self.__extra_mapping
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
index b2c9505a9..b8e170f2a 100644
--- a/src/pip/_internal/req/constructors.py
+++ b/src/pip/_internal/req/constructors.py
@@ -206,8 +206,8 @@ def parse_req_from_editable(editable_req: str) -> RequirementParts:
     if name is not None:
         try:
             req: Optional[Requirement] = Requirement(name)
-        except InvalidRequirement:
-            raise InstallationError(f"Invalid requirement: '{name}'")
+        except InvalidRequirement as exc:
+            raise InstallationError(f"Invalid requirement: {name!r}: {exc}")
     else:
         req = None
 
@@ -360,7 +360,7 @@ def parse_req_from_line(name: str, line_source: Optional[str]) -> RequirementPar
     def _parse_req_string(req_as_string: str) -> Requirement:
         try:
             return get_requirement(req_as_string)
-        except InvalidRequirement:
+        except InvalidRequirement as exc:
             if os.path.sep in req_as_string:
                 add_msg = "It looks like a path."
                 add_msg += deduce_helpful_msg(req_as_string)
@@ -370,7 +370,7 @@ def parse_req_from_line(name: str, line_source: Optional[str]) -> RequirementPar
                 add_msg = "= is not a valid operator. Did you mean == ?"
             else:
                 add_msg = ""
-            msg = with_source(f"Invalid requirement: {req_as_string!r}")
+            msg = with_source(f"Invalid requirement: {req_as_string!r}: {exc}")
             if add_msg:
                 msg += f"\nHint: {add_msg}"
             raise InstallationError(msg)
@@ -429,8 +429,8 @@ def install_req_from_req_string(
 ) -> InstallRequirement:
     try:
         req = get_requirement(req_string)
-    except InvalidRequirement:
-        raise InstallationError(f"Invalid requirement: '{req_string}'")
+    except InvalidRequirement as exc:
+        raise InstallationError(f"Invalid requirement: {req_string!r}: {exc}")
 
     domains_not_allowed = [
         PyPI.file_storage_domain,


Test patch:
diff --git a/tests/functional/test_install_extras.py b/tests/functional/test_install_extras.py
index 4dd27bb9d..4e74f54a2 100644
--- a/tests/functional/test_install_extras.py
+++ b/tests/functional/test_install_extras.py
@@ -1,6 +1,7 @@
 import re
 import textwrap
 from os.path import join
+from pathlib import Path
 
 import pytest
 
@@ -252,3 +253,23 @@ def test_install_extras(script: PipTestEnvironment) -> None:
         "a",
     )
     script.assert_installed(a="1", b="1", dep="1", meh="1")
+
+
+def test_install_setuptools_extras_inconsistency(
+    script: PipTestEnvironment, tmp_path: Path
+) -> None:
+    test_project_path = tmp_path.joinpath("test")
+    test_project_path.mkdir()
+    test_project_path.joinpath("setup.py").write_text(
+        textwrap.dedent(
+            """
+                from setuptools import setup
+                setup(
+                    name='test',
+                    version='0.1',
+                    extras_require={'extra_underscored': ['packaging']},
+                )
+            """
+        )
+    )
+    script.pip("install", "--dry-run", test_project_path)
diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py
index 5e3c640a5..dd0af289e 100644
--- a/tests/unit/test_req.py
+++ b/tests/unit/test_req.py
@@ -734,7 +734,7 @@ class TestInstallRequirement:
         with pytest.raises(InstallationError) as e:
             install_req_from_line(test_name)
         err_msg = e.value.args[0]
-        assert f"Invalid requirement: '{test_name}'" == err_msg
+        assert err_msg.startswith(f"Invalid requirement: '{test_name}'")
 
     def test_requirement_file(self) -> None:
         req_file_path = os.path.join(self.tempdir, "test.txt")
diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py
index ce751afe2..f4f98b190 100644
--- a/tests/unit/test_req_file.py
+++ b/tests/unit/test_req_file.py
@@ -269,8 +269,10 @@ class TestProcessLine:
             )
 
         expected = (
-            "Invalid requirement: 'my-package=1.0' "
-            "(from line 3 of path/requirements.txt)\n"
+            "Invalid requirement: 'my-package=1.0': "
+            "Expected end or semicolon (after name and no valid version specifier)\n"
+            "    my-package=1.0\n"
+            "              ^ (from line 3 of path/requirements.txt)\n"
             "Hint: = is not a valid operator. Did you mean == ?"
         )
         assert str(exc.value) == expected
diff --git a/tests/unit/test_req_install.py b/tests/unit/test_req_install.py
index 4bb71a743..22b98f86d 100644
--- a/tests/unit/test_req_install.py
+++ b/tests/unit/test_req_install.py
@@ -60,7 +60,12 @@ class TestInstallRequirementFrom:
         with pytest.raises(InstallationError) as excinfo:
             install_req_from_req_string("http:/this/is/invalid")
 
-        assert str(excinfo.value) == ("Invalid requirement: 'http:/this/is/invalid'")
+        assert str(excinfo.value) == (
+            "Invalid requirement: 'http:/this/is/invalid': "
+            "Expected end or semicolon (after name and no valid version specifier)\n"
+            "    http:/this/is/invalid\n"
+            "        ^"
+        )
 
     def test_install_req_from_string_without_comes_from(self) -> None:
         """