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

Canonicalize names for requirements comparison #696

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Changelog

* Enforce that the entire marker string is parsed (:issue:`687`)
* Requirement parsing no longer automatically validates the URL (:issue:`120`)
* Canonicalize names for requirements comparison (:issue:`644`)

23.1 - 2023-04-12
~~~~~~~~~~~~~~~~~
Expand Down
29 changes: 18 additions & 11 deletions src/packaging/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.

from typing import Any, List, Optional, Set
from typing import Any, Iterator, Optional, Set

from ._parser import parse_requirement as _parse_requirement
from ._tokenizer import ParserSyntaxError
from .markers import Marker, _normalize_extra_values
from .specifiers import SpecifierSet
from .utils import canonicalize_name


class InvalidRequirement(ValueError):
Expand Down Expand Up @@ -44,38 +45,44 @@ def __init__(self, requirement_string: str) -> None:
self.marker = Marker.__new__(Marker)
self.marker._markers = _normalize_extra_values(parsed.marker)

def __str__(self) -> str:
parts: List[str] = [self.name]
def _iter_parts(self, name: str) -> Iterator[str]:
yield name

if self.extras:
formatted_extras = ",".join(sorted(self.extras))
parts.append(f"[{formatted_extras}]")
yield f"[{formatted_extras}]"

if self.specifier:
parts.append(str(self.specifier))
yield str(self.specifier)

if self.url:
parts.append(f"@ {self.url}")
yield f"@ {self.url}"
if self.marker:
parts.append(" ")
yield " "

if self.marker:
parts.append(f"; {self.marker}")
yield f"; {self.marker}"

return "".join(parts)
def __str__(self) -> str:
return "".join(self._iter_parts(self.name))

def __repr__(self) -> str:
return f"<Requirement('{self}')>"

def __hash__(self) -> int:
return hash((self.__class__.__name__, str(self)))
return hash(
(
self.__class__.__name__,
*self._iter_parts(canonicalize_name(self.name)),
)
)

def __eq__(self, other: Any) -> bool:
if not isinstance(other, Requirement):
return NotImplemented

return (
self.name == other.name
canonicalize_name(self.name) == canonicalize_name(other.name)
and self.extras == other.extras
and self.specifier == other.specifier
and self.url == other.url
Expand Down
19 changes: 18 additions & 1 deletion tests/test_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
),
]

EQUIVALENT_DEPENDENCIES = [
("scikit-learn==1.0.1", "scikit_learn==1.0.1"),
]

DIFFERENT_DEPENDENCIES = [
("package_one", "package_two"),
("packaging>20.1", "packaging>=20.1"),
Expand Down Expand Up @@ -632,12 +636,25 @@ def test_str_and_repr(

@pytest.mark.parametrize("dep1, dep2", EQUAL_DEPENDENCIES)
def test_equal_reqs_equal_hashes(self, dep1: str, dep2: str) -> None:
"""Requirement objects created from equivalent strings should be equal."""
"""Requirement objects created from equal strings should be equal."""
# GIVEN / WHEN
req1, req2 = Requirement(dep1), Requirement(dep2)

assert req1 == req2
assert hash(req1) == hash(req2)

@pytest.mark.parametrize("dep1, dep2", EQUIVALENT_DEPENDENCIES)
def test_equivalent_reqs_equal_hashes_unequal_strings(
self, dep1: str, dep2: str
) -> None:
"""Requirement objects created from equivalent strings should be equal,
even though their string representation will not."""
# GIVEN / WHEN
req1, req2 = Requirement(dep1), Requirement(dep2)

assert req1 == req2
assert hash(req1) == hash(req2)
assert str(req1) != str(req2)

@pytest.mark.parametrize("dep1, dep2", DIFFERENT_DEPENDENCIES)
def test_different_reqs_different_hashes(self, dep1: str, dep2: str) -> None:
Expand Down