Repository: pypa/pip

Issue:
Show the detailed error when facing invalid requirements
### Description

From https://github.com/pypa/pip/issues/12691#issuecomment-2120014310

`pip install --dry-run "python-hdf4>=0.10.0+dummy"` prints `ERROR: Invalid requirement: 'python-hdf4>=0.10.0+dummy'`.


### Expected behavior

The detailed, more informative error should be printed:

```
InvalidRequirement: Local version label can only be used with `==` or `!=` operators
    python-hdf4>=0.10.0+dummy
               ~~~~~~~~^
```

### pip version

24.1b1

### Python version

any

### OS

any

### How to Reproduce

`pip install --dry-run "python-hdf4>=0.10.0+dummy"`

### Output

_No response_

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

Discussion:
uranusjr: Seems to, GitHub is having trouble fetching VCM repos (even `file://` ones) for some reason.
notatallshaw: Test failures have been appearing on multiple PRs for a few days, I noticed but didn't get chance to investigate.

Patch:
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/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/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:
         """