diff --git a/docs/html/topics/vcs-support.md b/docs/html/topics/vcs-support.md
index 70bb5beb9dc..d108f4d825d 100644
--- a/docs/html/topics/vcs-support.md
+++ b/docs/html/topics/vcs-support.md
@@ -139,9 +139,14 @@ option.
pip looks at 2 fragments for VCS URLs:
- `egg`: For specifying the "project name" for use in pip's dependency
- resolution logic. eg: `egg=project_name`
+ resolution logic. e.g.: `egg=project_name`
+
+ The `egg` fragment **should** be a bare
+ [PEP 508](https://peps.python.org/pep-0508/) project name. Anything else
+ is not guaranteed to work.
+
- `subdirectory`: For specifying the path to the Python package, when it is not
- in the root of the VCS directory. eg: `pkg_dir`
+ in the root of the VCS directory. e.g.: `pkg_dir`
````{admonition} Example
If your repository layout is:
diff --git a/news/10265.bugfix.rst b/news/10265.removal.rst
similarity index 100%
rename from news/10265.bugfix.rst
rename to news/10265.removal.rst
diff --git a/news/11617.bugfix.rst b/news/11617.bugfix.rst
new file mode 100644
index 00000000000..02346e49c42
--- /dev/null
+++ b/news/11617.bugfix.rst
@@ -0,0 +1,3 @@
+Deprecated a historical ambiguity in how ``egg`` fragments in URL-style
+requirements are formatted and handled. ``egg`` fragments that do not look
+like PEP 508 names now produce a deprecation warning.
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
index c792d128bcf..c7c4b0e9b25 100644
--- 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=([^&]*)")
diff --git a/tests/unit/test_link.py b/tests/unit/test_link.py
index 99ed0aba76e..df4957d5974 100644
--- 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",
[