Repository: pypa/pip

Issue:
Restrict unused version pins in URL/VCS requirements?
### What's the problem this feature will solve?

I noticed this behavior while debugging/repro-ing https://github.com/pypa/pip-audit/issues/382: `pip install -r requirements.txt` will happily install a VCS or other URL dependency that contains a fragment string like this:

```
hypothesis @ git+https://github.com/HypothesisWorks/hypothesis.git@bb6b55ad8d#egg=hypothesis==9.9.9&subdirectory=hypothesis-python
```

The `egg=hypothesis==9.9.9` fragment implies that `hypothesis==9.9.9` is being installed from this URL, but that version specifier is actually ignored and the real version at that VCS ref (6.56.3) is installed instead.

AFAICT this version pin was never supported in the first place (https://github.com/pypa/pip/issues/5384 says that the egg fragment has never supported 508-style specs), but it's a little surprising (as an end user) for it to silently be ignored rather than producing a warning or requirements parsing error.



### Describe the solution you'd like

Ideally, `pip` would produce a hard error (or at least a warning) here, since the supplied version specifier is (1) ineffective and (2) indicates user confusion about what they're asking for.

### Alternative Solutions

No alternative solution is necessary, since this isn't broken per se.

### Additional context

`pip-audit` context: https://github.com/pypa/pip-audit/issues/382

This issue also manifests in third-party requirements-file parsers, like `pip-audit` and `pip-requirements-parser`. If this behavior is changed in `pip` itself, I can submit patches there as well.

### Code of Conduct

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


Discussion:
Also: I'm happy to submit the patch for this! I just wanted to file it as an issue first, to register interest/awareness.
+1 on giving an error for anything other than `#egg=name` where `name` follows [the specification for a project name](https://packaging.python.org/en/latest/specifications/core-metadata/#name).
> +1 on giving an error for anything other than `#egg=name` where `name` follows [the specification for a project name](https://packaging.python.org/en/latest/specifications/core-metadata/#name).

Awesome! I'll take a stab at this tomorrow.

Resolving patch:
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -18,6 +18,7 @@
     Union,
 )
 
+from pip._internal.utils.deprecation import deprecated
 from pip._internal.utils.filetypes import WHEEL_EXTENSION
 from pip._internal.utils.hashes import Hashes
 from pip._internal.utils.misc import (
@@ -166,6 +167,7 @@ class Link(KeyBasedCompareMixin):
         "dist_info_metadata",
         "link_hash",
         "cache_link_parsing",
+        "egg_fragment",
     ]
 
     def __init__(
@@ -229,6 +231,7 @@ def __init__(
         super().__init__(key=url, defining_class=Link)
 
         self.cache_link_parsing = cache_link_parsing
+        self.egg_fragment = self._egg_fragment()
 
     @classmethod
     def from_json(
@@ -358,12 +361,28 @@ def url_without_fragment(self) -> str:
 
     _egg_fragment_re = re.compile(r"[#&]egg=([^&]*)")
 
-    @property
-    def egg_fragment(self) -> Optional[str]:
+    # Per PEP 508.
+    _project_name_re = re.compile(
+        r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
+    )
+
+    def _egg_fragment(self) -> Optional[str]:
         match = self._egg_fragment_re.search(self._url)
         if not match:
             return None
-        return match.group(1)
+
+        # An egg fragment looks like a PEP 508 project name, along with
+        # an optional extras specifier. Anything else is invalid.
+        project_name = match.group(1)
+        if not self._project_name_re.match(project_name):
+            deprecated(
+                reason=f"{self} contains an egg fragment with a non-PEP 508 name",
+                replacement="to use the req @ url syntax, and remove the egg fragment",
+                gone_in="25.0",
+                issue=11617,
+            )
+
+        return project_name
 
     _subdirectory_fragment_re = re.compile(r"[#&]subdirectory=([^&]*)")
 


Test patch:
diff --git a/tests/unit/test_link.py b/tests/unit/test_link.py
--- a/tests/unit/test_link.py
+++ b/tests/unit/test_link.py
@@ -80,6 +80,37 @@ def test_fragments(self) -> None:
         assert "eggname" == Link(url).egg_fragment
         assert "subdir" == Link(url).subdirectory_fragment
 
+        # Extras are supported and preserved in the egg fragment,
+        # even the empty extras specifier.
+        # This behavior is deprecated and will change in pip 25.
+        url = "git+https://example.com/package#egg=eggname[extra]"
+        assert "eggname[extra]" == Link(url).egg_fragment
+        assert None is Link(url).subdirectory_fragment
+        url = "git+https://example.com/package#egg=eggname[extra1,extra2]"
+        assert "eggname[extra1,extra2]" == Link(url).egg_fragment
+        assert None is Link(url).subdirectory_fragment
+        url = "git+https://example.com/package#egg=eggname[]"
+        assert "eggname[]" == Link(url).egg_fragment
+        assert None is Link(url).subdirectory_fragment
+
+    @pytest.mark.xfail(reason="Behavior change scheduled for 25.0", strict=True)
+    @pytest.mark.parametrize(
+        "fragment",
+        [
+            # Package names in egg fragments must be in PEP 508 form.
+            "~invalid~package~name~",
+            # Version specifiers are not valid in egg fragments.
+            "eggname==1.2.3",
+            "eggname>=1.2.3",
+            # The extras specifier must be in PEP 508 form.
+            "eggname[!]",
+        ],
+    )
+    def test_invalid_egg_fragments(self, fragment: str) -> None:
+        url = f"git+https://example.com/package#egg={fragment}"
+        with pytest.raises(Exception):
+            Link(url)
+
     @pytest.mark.parametrize(
         "yanked_reason, expected",
         [