diff --git a/changelog.d/pr494.bugfix.rst b/changelog.d/pr494.bugfix.rst new file mode 100644 index 0000000..153f804 --- /dev/null +++ b/changelog.d/pr494.bugfix.rst @@ -0,0 +1,2 @@ +Bumping numeric prerelease and build identifiers now works when an identifier +exceeds Python's integer string conversion limit. diff --git a/src/semver/version.py b/src/semver/version.py index 7ead382..c99542a 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -293,6 +293,20 @@ def __iter__(self) -> VersionIterator: """Return iter(self).""" yield from self.to_tuple() + @staticmethod + def _increment_number(number: str) -> str: + """Increment an ASCII decimal string without a size limit.""" + if not number.isascii(): + return str(int(number) + 1) + + number = number.lstrip("0") + prefix = number.rstrip("9") + if prefix: + return prefix[:-1] + str(int(prefix[-1]) + 1) + "0" * ( + len(number) - len(prefix) + ) + return "1" + "0" * len(number) + @staticmethod def _increment_prerelease(string: str) -> str: """ @@ -304,7 +318,7 @@ def _increment_prerelease(string: str) -> str: """ match = Version._LAST_PRERELEASE.search(string) if match: - next_ = str(int(match.group(2)) + 1) + next_ = Version._increment_number(match.group(2)) string = match.group(1) + next_ if match.group(1) else next_ else: string += ".0" @@ -323,7 +337,7 @@ def _increment_string(string: str) -> str: """ match = Version._LAST_NUMBER.search(string) if match: - next_ = str(int(match.group(1)) + 1) + next_ = Version._increment_number(match.group(1)) start, end = match.span(1) string = string[: max(end - len(next_), start)] + next_ + string[end:] return string @@ -553,9 +567,9 @@ def next_version(self, part: str, prerelease_token: str = "rc") -> "Version": rf"{re.escape(prerelease_token)}(\d+)", version.prerelease ) if match: - num = int(match.group(1)) + num = cls._increment_number(match.group(1)) return version.replace( - prerelease=f"{prerelease_token}{num + 1}", build=None + prerelease=f"{prerelease_token}{num}", build=None ) if not ( diff --git a/tests/test_bump_large_identifiers.py b/tests/test_bump_large_identifiers.py new file mode 100644 index 0000000..d2f0d3c --- /dev/null +++ b/tests/test_bump_large_identifiers.py @@ -0,0 +1,46 @@ +import pytest + +from semver import Version + + +@pytest.mark.parametrize( + "number,expected", + [ + ("1" * 5000, "1" * 4999 + "2"), + ("9" * 5000, "1" + "0" * 5000), + ("1" + "9" * 4999, "2" + "0" * 4999), + ], + ids=["no-carry", "new-digit", "carry"], +) +@pytest.mark.parametrize("part", ["prerelease", "build", "old-style-prerelease"]) +def test_bump_large_numeric_identifier(number, expected, part): + if part == "build": + original = "1.2.3-rc.1+build." + number + ".tail" + version = Version.parse(original) + result = version.bump_build() + expected_version = "1.2.3-rc.1+build." + expected + ".tail" + elif part == "old-style-prerelease": + original = "1.2.3-rc" + number + "+build.1" + version = Version.parse(original) + result = version.next_version("prerelease", "rc") + expected_version = "1.2.3-rc" + expected + else: + original = "1.2.3-rc." + number + "+build.1" + version = Version.parse(original) + result = version.bump_prerelease() + expected_version = "1.2.3-rc." + expected + + assert str(result) == expected_version + assert Version.is_valid(str(result)) + assert str(version) == original + + +def test_bump_preserves_constructor_unicode_digit_behavior(): + version = Version(1, 2, 3, prerelease="rc.\u0660\u0660\u0669") + assert version.bump_prerelease().prerelease == "rc.10" + + version = Version(1, 2, 3, build="build.\u0660\u0660\u0669") + assert version.bump_build().build == "build.\u066010" + + version = Version(1, 2, 3, prerelease="rc\u0660\u0660\u0669") + assert version.next_version("prerelease", "rc").prerelease == "rc10"