From 0607224816f20cab697ffc83ed809e391916002d Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Wed, 30 Sep 2026 15:50:11 -0700 Subject: [PATCH 1/2] Compare long numeric prerelease identifiers without integer conversion --- changelog.d/487.bugfix.rst | 2 + src/semver/version.py | 26 ++++++------- tests/test_compare.py | 80 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 changelog.d/487.bugfix.rst diff --git a/changelog.d/487.bugfix.rst b/changelog.d/487.bugfix.rst new file mode 100644 index 00000000..e5684a28 --- /dev/null +++ b/changelog.d/487.bugfix.rst @@ -0,0 +1,2 @@ +Comparing versions with long numeric prerelease identifiers no longer raises +``ValueError`` because of Python's integer string conversion limit. diff --git a/src/semver/version.py b/src/semver/version.py index 75e73be2..35d3e1c7 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -185,21 +185,21 @@ def _validate_identifiers( @classmethod def _nat_cmp(cls, a: Optional[str], b: Optional[str]) -> int: - def cmp_prerelease_tag(a, b): - if isinstance(a, int) and isinstance(b, int): - return _cmp(a, b) - elif isinstance(a, int): - return -1 - elif isinstance(b, int): - return 1 - else: - return _cmp(a, b) - - a_parts = [int(x) if x.isdigit() else x for x in (a or "").split(".")] - b_parts = [int(x) if x.isdigit() else x for x in (b or "").split(".")] + def prerelease_key(identifier: str) -> tuple[int, int, str]: + if identifier.isdigit(): + if not identifier.isascii(): + identifier = str(int(identifier)) + # Compare decimal values without converting arbitrarily long + # ASCII identifiers to integers. Keep constructor leading zeros. + identifier = identifier.lstrip("0") or "0" + return (0, len(identifier), identifier) + return (1, 0, identifier) + + a_parts = [prerelease_key(x) for x in (a or "").split(".")] + b_parts = [prerelease_key(x) for x in (b or "").split(".")] for sub_a, sub_b in zip(a_parts, b_parts): - cmp_result = cmp_prerelease_tag(sub_a, sub_b) + cmp_result = _cmp(sub_a, sub_b) if cmp_result != 0: return cmp_result diff --git a/tests/test_compare.py b/tests/test_compare.py index dffa98b8..354f3fe3 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -4,6 +4,86 @@ from semver import Version, compare +def test_should_compare_long_numeric_prerelease(): + value = "0.0.0-" + "1" * 5000 + assert Version.is_valid(value) + version = Version.parse(value) + assert version.compare(value) == 0 + assert compare(value, "0.0.0-2") == 1 + assert compare("0.0.0-2", value) == -1 + + +@pytest.mark.parametrize( + "lower,higher", + [ + pytest.param("9" * 4999, "1" + "0" * 4999, id="digit-count"), + pytest.param( + "1" + "0" * 4998 + "1", + "1" + "0" * 4998 + "2", + id="equal-length", + ), + pytest.param("1" * 5000, "alpha", id="numeric-before-text"), + pytest.param("alpha." + "1" * 5000, "alpha." + "1" * 5000 + ".1", id="prefix"), + ], +) +def test_should_order_long_numeric_prereleases(lower, higher): + low = Version.parse("0.0.0-" + lower) + high = Version.parse("0.0.0-" + higher) + assert low.compare(high) == -1 + assert high.compare(low) == 1 + assert sorted([high, low]) == [low, high] + + +def test_should_ignore_build_and_preserve_release_order_with_long_prerelease(): + version = Version.parse("0.0.0-" + "1" * 5000) + assert version == version.replace(build="other") + assert version < Version(0, 0, 0) + + +@pytest.mark.parametrize( + "left,right,expected", + [ + ("0001", "1", 0), + ("000", "0", 0), + ("02", "10", -1), + ("01.2", "1.2", 0), + ("2a", "10a", 1), + ("01a", "1a", -1), + ("-1", "0", 1), + ("", None, 0), + ("\u0661\u0662", "12", 0), + ("\uff11\uff12", "13", -1), + ("1\u0662", "12", 0), + ("\u0660\u0660\u0661", "1", 0), + ], +) +def test_should_preserve_constructor_prerelease_comparison(left, right, expected): + first = Version(0, 0, 0, prerelease=left) + second = Version(0, 0, 0, prerelease=right) + assert first.compare(second) == expected + assert second.compare(first) == -expected + assert first.prerelease == left + assert second.prerelease == right + + +@pytest.mark.parametrize("prerelease", ["01", "01.2", "\u0661\u0662", "\uff11\uff12"]) +def test_should_reject_noncanonical_prerelease_when_parsing(prerelease): + with pytest.raises(ValueError): + Version.parse("0.0.0-" + prerelease) + + +@pytest.mark.parametrize( + "left,right", [("\u00b2", "2"), ("a.\u00b2", "b.0"), ("a.0", "b.\u00b2")] +) +def test_should_preserve_eager_invalid_unicode_digit_error(left, right): + with pytest.raises(ValueError): + Version(0, 0, 0, prerelease=left).compare(Version(0, 0, 0, prerelease=right)) + + +def test_should_compare_core_before_invalid_prerelease(): + assert Version(0, 0, 0, prerelease="\u00b2") < Version(1, 0, 0, prerelease="\u00b2") + + @pytest.mark.parametrize( "left,right", [ From 4d7777a2d92611be601ef44bdf4fc185511498c9 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Thu, 1 Oct 2026 11:49:06 -0700 Subject: [PATCH 2/2] Simplify prerelease comparison key control flow --- src/semver/version.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/semver/version.py b/src/semver/version.py index 35d3e1c7..67274467 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -186,14 +186,16 @@ def _validate_identifiers( @classmethod def _nat_cmp(cls, a: Optional[str], b: Optional[str]) -> int: def prerelease_key(identifier: str) -> tuple[int, int, str]: - if identifier.isdigit(): - if not identifier.isascii(): - identifier = str(int(identifier)) - # Compare decimal values without converting arbitrarily long - # ASCII identifiers to integers. Keep constructor leading zeros. - identifier = identifier.lstrip("0") or "0" - return (0, len(identifier), identifier) - return (1, 0, identifier) + if not identifier.isdigit(): + return (1, 0, identifier) + + if not identifier.isascii(): + identifier = str(int(identifier)) + + # Compare decimal values without converting arbitrarily long + # ASCII identifiers to integers. Keep constructor leading zeros. + normalized = identifier.lstrip("0") or "0" + return (0, len(normalized), normalized) a_parts = [prerelease_key(x) for x in (a or "").split(".")] b_parts = [prerelease_key(x) for x in (b or "").split(".")]