From d25c99bc002b285e82af55d52c9dd9e959fa5319 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 8 Feb 2026 16:19:52 -0500 Subject: [PATCH 1/4] Speed up sorting by skipping property look up --- src/packaging/version.py | 166 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/packaging/version.py b/src/packaging/version.py index a11d46398..ffa0f34a2 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -548,6 +548,172 @@ def _key(self) -> CmpKey: ) return self._key_cache + def __hash__(self) -> int: + return hash(self._key) + + # Override comparison methods to use direct _key_cache access + def __lt__(self, other: _BaseVersion) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache < other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__lt__(other) + + def __le__(self, other: _BaseVersion) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache <= other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__le__(other) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache == other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__eq__(other) + + def __ge__(self, other: _BaseVersion) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache >= other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__ge__(other) + + def __gt__(self, other: _BaseVersion) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache > other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__gt__(other) + + def __ne__(self, other: object) -> bool: + if isinstance(other, Version): + if self._key_cache is None: + self._key_cache = _cmpkey( + self._epoch, + self._release, + self._pre, + self._post, + self._dev, + self._local, + ) + if other._key_cache is None: + other._key_cache = _cmpkey( + other._epoch, + other._release, + other._pre, + other._post, + other._dev, + other._local, + ) + return self._key_cache != other._key_cache + + if not isinstance(other, _BaseVersion): + return NotImplemented + + return super().__ne__(other) + @property @_deprecated("Version._version is private and will be removed soon") def _version(self) -> _Version: From 3fe0990adc3ced8448e70751154c8ce8c06d14f5 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 8 Feb 2026 17:01:15 -0500 Subject: [PATCH 2/4] Tests for coverage --- tests/test_version.py | 85 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/test_version.py b/tests/test_version.py index 68d5dbe44..1466eb1c8 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -12,7 +12,13 @@ import pretend import pytest -from packaging.version import InvalidVersion, Version, _VersionReplace, parse +from packaging.version import ( + InvalidVersion, + Version, + _BaseVersion, + _VersionReplace, + parse, +) if typing.TYPE_CHECKING: from collections.abc import Callable @@ -103,6 +109,27 @@ def test_parse_raises() -> None: ] +# Simple _BaseVersion subclass for testing comparison with non-Version types +class SimpleVersion(_BaseVersion): + """A simple _BaseVersion subclass for testing cross-type comparisons.""" + + def __init__(self, key: typing.Any) -> None: # noqa: ANN401 + # If key is a string, parse it as a version to create a compatible key + if isinstance(key, str): + parsed = Version(key) + self._key_tuple = parsed._key + else: + self._key_tuple = key + + @property + def _key(self) -> typing.Any: # noqa: ANN401 + return self._key_tuple + + @_key.setter + def _key(self, value: typing.Any) -> None: # noqa: ANN401 + self._key_tuple = value + + class TestVersion: @pytest.mark.parametrize("version", VERSIONS) def test_valid_versions(self, version: str) -> None: @@ -800,6 +827,62 @@ def test_compare_other(self, op: str, expected: bool) -> None: assert getattr(operator, op)(Version("1"), other) is expected + @pytest.mark.parametrize( + "op", ["__lt__", "__le__", "__eq__", "__ge__", "__gt__", "__ne__"] + ) + def test_base_version_notimplemented_with_non_base_version(self, op: str) -> None: + """Test _BaseVersion returns NotImplemented with non-_BaseVersion.""" + v = SimpleVersion("1.0") + assert getattr(v, op)(1) is NotImplemented + + def test_base_version_hash(self) -> None: + """Test that _BaseVersion hash works""" + v = SimpleVersion("1.0") + assert isinstance(hash(v), int) + + def test_base_version_ne_with_base_version(self) -> None: + """Test _BaseVersion.__ne__ with another _BaseVersion.""" + v1 = SimpleVersion("1.0") + v2 = SimpleVersion("2.0") + assert v1 != v2 + + def test_version_compare_with_base_version_subclass(self) -> None: + """Test Version comparison with another _BaseVersion subclass""" + v1 = Version("1.0") + v2 = SimpleVersion("1.0") + + # All comparisons should work with compatible keys + assert v1 == v2 + assert v1 <= v2 + assert v1 >= v2 + assert v1 == v2 + assert not (v1 < v2) + assert not (v1 > v2) + + # Test with different versions to exercise != path + v3 = Version("1.0") + v4 = SimpleVersion("2.0") + assert v3 != v4 + + def test_version_ne_with_uncached_keys(self) -> None: + """Test Version.__ne__ populates cache when comparing with another Version""" + v1 = Version("1.0") + v2 = Version("2.0") + + # Test with both caches None + result = v1 != v2 + assert result is True + + # Test with v1 cached, v3 uncached + v3 = Version("1.5") + result = v1 != v3 + assert result is True + + # Test with v3 cached, v4 uncached (the reverse case) + v4 = Version("1.2") + result = v4 != v3 + assert result is True + def test_major_version(self) -> None: assert Version("2.1.0").major == 2 From 4a41224886c4e4dd3411fb8ac5693f3d9fbbcfb1 Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Sun, 8 Feb 2026 17:14:15 -0500 Subject: [PATCH 3/4] more test coverage --- tests/test_version.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_version.py b/tests/test_version.py index 1466eb1c8..95d0aa008 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -883,6 +883,20 @@ def test_version_ne_with_uncached_keys(self) -> None: result = v4 != v3 assert result is True + def test_version_le_with_uncached_keys(self) -> None: + """Test Version.__le__ populates cache when comparing with another Version""" + v1 = Version("1.0") + v2 = Version("2.0") + + # Test <= with both caches None + result = v1 <= v2 + assert result is True + + # Test with v1 cached (from above), v3 uncached + v3 = Version("1.5") + result = v1 <= v3 + assert result is True + def test_major_version(self) -> None: assert Version("2.1.0").major == 2 From 1d9a9420e70970a08e4f08b6d18ca20e090f5e2b Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Fri, 6 Mar 2026 08:46:06 -0500 Subject: [PATCH 4/4] Add comments --- src/packaging/version.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/packaging/version.py b/src/packaging/version.py index ffa0f34a2..936dc7e62 100644 --- a/src/packaging/version.py +++ b/src/packaging/version.py @@ -548,10 +548,13 @@ def _key(self) -> CmpKey: ) return self._key_cache + # __hash__ must be defined when __eq__ is overridden, + # otherwise Python sets __hash__ to None. def __hash__(self) -> int: return hash(self._key) # Override comparison methods to use direct _key_cache access + # This is faster than property access, especially before Python 3.12 def __lt__(self, other: _BaseVersion) -> bool: if isinstance(other, Version): if self._key_cache is None: