diff --git a/docs/markers.rst b/docs/markers.rst index ad253616..478b76e4 100644 --- a/docs/markers.rst +++ b/docs/markers.rst @@ -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 diff --git a/docs/requirements.rst b/docs/requirements.rst index e7c5a85a..4b4813a1 100644 --- a/docs/requirements.rst +++ b/docs/requirements.rst @@ -47,6 +47,14 @@ Usage set() >>> url_req.marker + >>> # 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 diff --git a/packaging/markers.py b/packaging/markers.py index cb640e8f..56d4b240 100644 --- a/packaging/markers.py +++ b/packaging/markers.py @@ -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: + # [ + # (, ')>, ), + # 'and', + # [ + # (, , ), + # 'or', + # (, , ) + # ] + # ] except ParseException as e: raise InvalidMarker( f"Invalid marker: {marker!r}, parse error at " @@ -288,6 +304,15 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"" + 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. diff --git a/packaging/requirements.py b/packaging/requirements.py index 53f9a3aa..a0c6217e 100644 --- a/packaging/requirements.py +++ b/packaging/requirements.py @@ -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, @@ -144,3 +144,18 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"" + + 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 + ) diff --git a/tests/test_markers.py b/tests/test_markers.py index c2640afe..7a7663ba 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -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"" + # 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 diff --git a/tests/test_requirements.py b/tests/test_requirements.py index f2c209c4..ad07de76 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -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"