Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restrict #egg= fragments to valid PEP 508 names #11617

Merged
merged 12 commits into from
Dec 28, 2022
11 changes: 9 additions & 2 deletions docs/html/topics/vcs-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,16 @@ 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 may additionally contain an extras specifier, e.g.:
`egg=project_name[dev,test]`.

Both the project name and extras specifier must appear in the form
defined by [PEP 508](https://peps.python.org/pep-0508/).
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

- `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:
Expand Down
8 changes: 8 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,11 @@ def __str__(self) -> str:
assert self.error is not None
message_part = f".\n{self.error}\n"
return f"Configuration file {self.reason}{message_part}"


class InvalidEggFragment(InstallationError):
"""A link's `#egg=` fragment doesn't look like a valid PEP 508 project
name."""

def __init__(self, fragment: str) -> None:
super().__init__(f"egg fragment is not a bare project name: {fragment}")
23 changes: 20 additions & 3 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
Union,
)

from pip._vendor.packaging.requirements import EXTRAS, NAME
from pip._vendor.pyparsing import Optional as Maybe
from pip._vendor.pyparsing import ParseException, stringEnd, stringStart

from pip._internal.exceptions import InvalidEggFragment
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.hashes import Hashes
from pip._internal.utils.misc import (
Expand Down Expand Up @@ -166,6 +171,7 @@ class Link(KeyBasedCompareMixin):
"dist_info_metadata",
"link_hash",
"cache_link_parsing",
"egg_fragment",
]

def __init__(
Expand Down Expand Up @@ -229,6 +235,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(
Expand Down Expand Up @@ -358,12 +365,22 @@ def url_without_fragment(self) -> str:

_egg_fragment_re = re.compile(r"[#&]egg=([^&]*)")

@property
def egg_fragment(self) -> Optional[str]:
_fragment_parser = stringStart + NAME + Maybe(EXTRAS) + stringEnd
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

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)
try:
self._fragment_parser.parseString(project_name)
except ParseException:
raise InvalidEggFragment(project_name)

return project_name

_subdirectory_fragment_re = re.compile(r"[#&]subdirectory=([^&]*)")

Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from pip._internal.exceptions import InvalidEggFragment
from pip._internal.models.link import Link, links_equivalent
from pip._internal.utils.hashes import Hashes

Expand Down Expand Up @@ -80,6 +81,35 @@ 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 (since PEP 508 allows it).
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
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
assert None is Link(url).subdirectory_fragment

@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(InvalidEggFragment):
Link(url)

@pytest.mark.parametrize(
"yanked_reason, expected",
[
Expand Down