Skip to content

Commit

Permalink
Add __hash__/__eq__ to requirements (#499)
Browse files Browse the repository at this point in the history
Co-authored-by: Brett Cannon <brett@python.org>
Co-authored-by: Pradyun Gedam <pradyunsg@gmail.com>
  • Loading branch information
3 people committed Mar 15, 2022
1 parent 4395da0 commit aebc072
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 2 deletions.
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"

0 comments on commit aebc072

Please sign in to comment.