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

Add __hash__/__eq__ to requirements #499

Merged
merged 16 commits into from Mar 15, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/markers.rst
Expand Up @@ -45,6 +45,14 @@ Usage
>>> extra_environment['extra'] = 'bar'
>>> extra.evaluate(environment=extra_environment)
True
>>> # You can do simple comparisons between marker objects:
>>> Marker("python_version > '3.6'") == Marker("python_version > '3.6'")
True
>>> # You can also perform simple comparisons between sets of markers:
>>> markers1 = {Marker("python_version > '3.6'"), Marker('os_name == "unix"')}
>>> markers2 = {Marker('os_name == "unix"'), Marker("python_version > '3.6'")}
>>> markers1 == markers2
True


Reference
Expand Down
8 changes: 8 additions & 0 deletions docs/requirements.rst
Expand Up @@ -47,6 +47,14 @@ Usage
set()
>>> url_req.marker
<Marker('os_name == "a"')>
>>> # You can do simple comparisons between requirement objects:
>>> Requirement("packaging") == Requirement("packaging")
True
>>> # You can also perform simple comparisons between sets of requirements:
>>> requirements1 = {Requirement("packaging"), Requirement("pip")}
>>> requirements2 = {Requirement("pip"), Requirement("packaging")}
>>> requirements1 == requirements2
True


Reference
Expand Down
25 changes: 25 additions & 0 deletions packaging/markers.py
Expand Up @@ -276,6 +276,22 @@ class Marker:
def __init__(self, marker: str) -> None:
try:
self._markers = _coerce_parse_result(MARKER.parseString(marker))
# The attribute `_markers` can be described in terms of a recursive type:
# MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]]
#
# For example, the following expression:
# python_version > "3.6" or (python_version == "3.6" and os_name == "unix")
#
# is parsed into:
# [
# (<Variable('python_version')>, <Op('>')>, <Value('3.6')>),
# 'and',
# [
# (<Variable('python_version')>, <Op('==')>, <Value('3.6')>),
# 'or',
# (<Variable('os_name')>, <Op('==')>, <Value('unix')>)
# ]
# ]
except ParseException as e:
raise InvalidMarker(
f"Invalid marker: {marker!r}, parse error at "
Expand All @@ -288,6 +304,15 @@ def __str__(self) -> str:
def __repr__(self) -> str:
return f"<Marker('{self}')>"

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

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

return str(self) == str(other)

def evaluate(self, environment: Optional[Dict[str, str]] = None) -> bool:
"""Evaluate a marker.

Expand Down
17 changes: 16 additions & 1 deletion packaging/requirements.py
Expand Up @@ -5,7 +5,7 @@
import re
import string
import urllib.parse
from typing import List, Optional as TOptional, Set
from typing import Any, List, Optional as TOptional, Set

from pyparsing import ( # noqa
Combine,
Expand Down Expand Up @@ -144,3 +144,18 @@ def __str__(self) -> str:

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

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

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

return (
self.name == other.name
and self.extras == other.extras
and self.specifier == other.specifier
and self.url == other.url
and self.marker == other.marker
)
43 changes: 42 additions & 1 deletion tests/test_markers.py
Expand Up @@ -202,10 +202,51 @@ def test_parses_invalid(self, marker_string):
),
],
)
def test_str_and_repr(self, marker_string, expected):
def test_str_repr_eq_hash(self, marker_string, expected):
m = Marker(marker_string)
assert str(m) == expected
assert repr(m) == f"<Marker({str(m)!r})>"
# Objects created from the same string should be equal.
assert m == Marker(marker_string)
# Objects created from the equivalent strings should also be equal.
assert m == Marker(expected)
# Objects created from the same string should have the same hash.
assert hash(Marker(marker_string)) == hash(Marker(marker_string))
# Objects created from equivalent strings should also have the same hash.
assert hash(Marker(marker_string)) == hash(Marker(expected))

@pytest.mark.parametrize(
("example1", "example2"),
[
# Test trivial comparisons.
('python_version == "2.7"', 'python_version == "3.7"'),
(
'python_version == "2.7"',
'python_version == "2.7" and os_name == "linux"',
),
(
'python_version == "2.7"',
'(python_version == "2.7" and os_name == "linux")',
),
# Test different precedence.
(
'python_version == "2.7" and (os_name == "linux" or '
'sys_platform == "win32")',
'python_version == "2.7" and os_name == "linux" or '
'sys_platform == "win32"',
),
],
)
def test_different_markers_different_hashes(self, example1, example2):
marker1, marker2 = Marker(example1), Marker(example2)
# Markers created from strings that are not equivalent should differ.
assert marker1 != marker2
# Different Marker objects should have different hashes.
assert hash(marker1) != hash(marker2)

def test_compare_markers_to_other_objects(self):
# Markers should not be comparable to other kinds of objects.
assert Marker("os_name == 'nt'") != "os_name == 'nt'"

def test_extra_with_no_extra_in_environment(self):
# We can't evaluate an extra if no extra is passed into the environment
Expand Down
54 changes: 54 additions & 0 deletions tests/test_requirements.py
Expand Up @@ -195,3 +195,57 @@ def test_parseexception_error_msg(self):
assert "Expected stringEnd" in str(e.value) or (
"Expected string_end" in str(e.value) # pyparsing>=3.0.0
)

EQUAL_DEPENDENCIES = [
("packaging>20.1", "packaging>20.1"),
(
'requests[security, tests]>=2.8.1,==2.8.*;python_version<"2.7"',
'requests [security,tests] >= 2.8.1, == 2.8.* ; python_version < "2.7"',
),
(
'importlib-metadata; python_version<"3.8"',
"importlib-metadata; python_version<'3.8'",
),
(
'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"',
"appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'testing'",
),
]

DIFFERENT_DEPENDENCIES = [
("packaging>20.1", "packaging>=20.1"),
("packaging>20.1", "packaging>21.1"),
("packaging>20.1", "package>20.1"),
(
'requests[security,tests]>=2.8.1,==2.8.*;python_version<"2.7"',
'requests [security,tests] >= 2.8.1 ; python_version < "2.7"',
),
(
'importlib-metadata; python_version<"3.8"',
"importlib-metadata; python_version<'3.7'",
),
(
'appdirs>=1.4.4,<2; os_name=="posix" and extra=="testing"',
"appdirs>=1.4.4,<2; os_name == 'posix' and extra == 'docs'",
),
]

@pytest.mark.parametrize("dep1, dep2", EQUAL_DEPENDENCIES)
def test_equal_reqs_equal_hashes(self, dep1, dep2):
# Requirement objects created from equivalent strings should be equal.
req1, req2 = Requirement(dep1), Requirement(dep2)
assert req1 == req2
# Equal Requirement objects should have the same hash.
assert hash(req1) == hash(req2)

@pytest.mark.parametrize("dep1, dep2", DIFFERENT_DEPENDENCIES)
def test_different_reqs_different_hashes(self, dep1, dep2):
# Requirement objects created from non-equivalent strings should differ.
req1, req2 = Requirement(dep1), Requirement(dep2)
assert req1 != req2
# Different Requirement objects should have different hashes.
assert hash(req1) != hash(req2)

def test_compare_reqs_to_other_objects(self):
# Requirement objects should not be comparable to other kinds of objects.
assert Requirement("packaging>=21.3") != "packaging>=21.3"