Repository: python-semver/python-semver (python)

Issue:
semver.bump_prerelease on *-rc9 results in lower version
### Which version of Python is the problem with?

3.8

### What semver version are you using?

_No response_

### What OS are you using? (Add more in the Environment section)

macOS

### Situation

When calling semver.bump_prerelease on a version with a prerelease of `-rc9`, it gets bumped to `-rc10`. However. according to the semver spec (https://semver.org/#spec-item-11 section 11.4 items 1+2), 'rc9' is considered a string and will be compared to 'rc10' using ASCII sort order, which considers 'rc10' to be lower than 'rc9'.

### How to reproduce

```
>>> import semver
>>> version1 = '1.0.0-rc9'
>>> version2 = semver.bump_prerelease(version1)
>>> version2
'1.0.0-rc10'
>>> semver.compare(version1, version2)
1
```



### Expected behavior

Not entirely sure how the bump_prerelease version should behalve when it gets `rc9`. ASCII-wise, after `9` you could consider `a`, but you get `rca`, `rcb` which feels a bit odd. Another option could be that the function just returns some kind of error, indicating it cannot determine how to increase `rc9` to a higher value.

### Environment

_No response_

Pull request description:
Fix #460 Improve bump_prerelease to alway get a newer version
This PR fixes #460 and contains the following changes:
* Bumping an existing prerelease always results in a newer prerelease
* Bumping an empty prerelease can optionally bump the patch version to ensure a newer version

I ran the test suites successfully, except for 'checks', which also failed before I made any change. I ensured I did not introduce any new errors in this suite. I also added quite a bunch of tests to validate the new functionality, and adjusted a few that changed due to changing existing functionality.

Open to any suggestions for improvement, as my Python experience is a bit limited :-)

Patch:
diff --git a/changelog.d/460.bugfix.rst b/changelog.d/460.bugfix.rst
new file mode 100644
index 0000000..dc21db8
--- /dev/null
+++ b/changelog.d/460.bugfix.rst
@@ -0,0 +1,9 @@
+:meth:`~semver.version.Version.bump_prerelease` will now add `.0` to an
+existing prerelease when the last segment of the current prerelease, split by
+dots (`.`), is not numeric. This is to ensure the new prerelease is considered
+higher than the previous one.
+
+:meth:`~semver.version.Version.bump_prerelease` now also support an argument
+`bump_when_empty` which will bump the patch version if there is no existing
+prerelease, to ensure the resulting version is considered a higher version than
+the previous one.
\ No newline at end of file
diff --git a/docs/usage/raise-parts-of-a-version.rst b/docs/usage/raise-parts-of-a-version.rst
index be89cf8..101a8c3 100644
--- a/docs/usage/raise-parts-of-a-version.rst
+++ b/docs/usage/raise-parts-of-a-version.rst
@@ -3,13 +3,14 @@ Raising Parts of a Version
 
 .. note::
 
-   Keep in mind, "raising" the pre-release only will make your
-   complete version *lower* than before.
+   Keep in mind, by default, "raising" the pre-release for a version without an existing
+   prerelease part, only will make your complete version *lower* than before.
 
    For example, having version ``1.0.0`` and raising the pre-release
    will lead to ``1.0.0-rc.1``, but ``1.0.0-rc.1`` is smaller than ``1.0.0``.
 
-   If you search for a way to take into account this behavior, look for the
+   To avoid this, set `bump_when_empty=True` in the
+   :meth:`~semver.version.Version.bump_prerelease` method, or by using the
    method :meth:`~semver.version.Version.next_version`
    in section :ref:`increase-parts-of-a-version`.
 
@@ -67,4 +68,14 @@ is not taken into account:
     >>> str(Version.parse("3.4.5-rc.1").bump_prerelease(''))
     '3.4.5-rc.2'
 
+To ensure correct ordering, we append `.0` to the last prerelease identifier
+if it's not numeric. This prevents cases where `rc9` would incorrectly sort
+lower than `rc10` (non-numeric identifiers are compared alphabetically):
+
+.. code-block:: python
+
+    >>> str(Version.parse("3.4.5-rc9").bump_prerelease())
+    '3.4.5-rc9.0'
+    >>> str(Version.parse("3.4.5-rc.9").bump_prerelease())
+    '3.4.5-rc.10'
 
diff --git a/src/semver/version.py b/src/semver/version.py
index ec24fbb..f9450f9 100644
--- a/src/semver/version.py
+++ b/src/semver/version.py
@@ -77,8 +77,10 @@ class Version:
     #: The names of the different parts of a version
     NAMES: ClassVar[Tuple[str, ...]] = tuple([item[1:] for item in __slots__])
 
-    #: Regex for number in a prerelease
+    #: Regex for number in a build
     _LAST_NUMBER: ClassVar[Pattern[str]] = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
+    #: Regex for number in a prerelease
+    _LAST_PRERELEASE: ClassVar[Pattern[str]] = re.compile(r"^(.*\.)?(\d+)$")
     #: Regex template for a semver version
     _REGEX_TEMPLATE: ClassVar[
         str
@@ -245,6 +247,23 @@ class Version:
         """Return iter(self)."""
         yield from self.to_tuple()
 
+    @staticmethod
+    def _increment_prerelease(string: str) -> str:
+        """
+        Check if the last part of a dot-separated string is numeric. If yes,
+        increase them. Else, add '.0'
+
+        :param string: the prerelease version to increment
+        :return: the incremented string
+        """
+        match = Version._LAST_PRERELEASE.search(string)
+        if match:
+            next_ = str(int(match.group(2)) + 1)
+            string = match.group(1) + next_ if match.group(1) else next_
+        else:
+            string += ".0"
+        return string
+
     @staticmethod
     def _increment_string(string: str) -> str:
         """
@@ -305,35 +324,52 @@ class Version:
         cls = type(self)
         return cls(self._major, self._minor, self._patch + 1)
 
-    def bump_prerelease(self, token: Optional[str] = "rc") -> "Version":
+    def bump_prerelease(
+        self,
+        token: Optional[str] = "rc",
+        bump_when_empty: Optional[bool] = False
+    ) -> "Version":
         """
         Raise the prerelease part of the version, return a new object but leave
         self untouched.
 
+        .. versionchanged:: 3.1.0
+           Parameter `bump_when_empty` added. When set to true, bumps the patch version
+           when called with a version that has no prerelease segment, so the return
+           value will be considered a newer version.
+
+           Adds `.0` to the prerelease if the last part of the dot-separated
+           prerelease is not a number.
+
         :param token: defaults to ``'rc'``
         :return: new :class:`Version` object with the raised prerelease part.
             The original object is not modified.
 
         >>> ver = semver.parse("3.4.5")
         >>> ver.bump_prerelease().prerelease
-        'rc.2'
+        'rc.1'
         >>> ver.bump_prerelease('').prerelease
         '1'
         >>> ver.bump_prerelease(None).prerelease
         'rc.1'
+        >>> str(ver.bump_prerelease(bump_when_empty=True))
+        '3.4.6-rc.1'
         """
         cls = type(self)
+        patch = self._patch
         if self._prerelease is not None:
-            prerelease = self._prerelease
-        elif token == "":
-            prerelease = "0"
-        elif token is None:
-            prerelease = "rc.0"
+            prerelease = cls._increment_prerelease(self._prerelease)
         else:
-            prerelease = str(token) + ".0"
+            if bump_when_empty:
+                patch += 1
+            if token == "":
+                prerelease = "1"
+            elif token is None:
+                prerelease = "rc.1"
+            else:
+                prerelease = str(token) + ".1"
 
-        prerelease = cls._increment_string(prerelease)
-        return cls(self._major, self._minor, self._patch, prerelease)
+        return cls(self._major, self._minor, patch, prerelease)
 
     def bump_build(self, token: Optional[str] = "build") -> "Version":
         """
@@ -445,10 +481,8 @@ build='build.10')
         # Only check the main parts:
         if part in cls.NAMES[:3]:
             return getattr(version, "bump_" + part)()
-
-        if not version.prerelease:
-            version = version.bump_patch()
-        return version.bump_prerelease(prerelease_token)
+        else:
+            return version.bump_prerelease(prerelease_token, bump_when_empty=True)
 
     @_comparator
     def __eq__(self, other: Comparable) -> bool:  # type: ignore
diff --git a/tox.ini b/tox.ini
index 5c9db17..d39ce81 100644
--- a/tox.ini
+++ b/tox.ini
@@ -29,6 +29,7 @@ deps =
     setuptools-scm
 setenv =
     PIP_DISABLE_PIP_VERSION_CHECK = 1
+downloads = true
 
 
 [testenv:mypy]


Test patch:
diff --git a/tests/test_bump.py b/tests/test_bump.py
index 34e0b2a..fcbedf4 100644
--- a/tests/test_bump.py
+++ b/tests/test_bump.py
@@ -6,6 +6,7 @@ from semver import (
     bump_minor,
     bump_patch,
     bump_prerelease,
+    compare,
     parse_version_info,
 )
 
@@ -32,62 +33,101 @@ def test_should_versioninfo_bump_minor_and_patch():
     v = parse_version_info("3.4.5")
     expected = parse_version_info("3.5.1")
     assert v.bump_minor().bump_patch() == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_patch_and_prerelease():
     v = parse_version_info("3.4.5-rc.1")
     expected = parse_version_info("3.4.6-rc.1")
     assert v.bump_patch().bump_prerelease() == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_patch_and_prerelease_with_token():
     v = parse_version_info("3.4.5-dev.1")
     expected = parse_version_info("3.4.6-dev.1")
     assert v.bump_patch().bump_prerelease("dev") == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_prerelease_and_build():
     v = parse_version_info("3.4.5-rc.1+build.1")
     expected = parse_version_info("3.4.5-rc.2+build.2")
     assert v.bump_prerelease().bump_build() == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_prerelease_and_build_with_token():
     v = parse_version_info("3.4.5-rc.1+b.1")
     expected = parse_version_info("3.4.5-rc.2+b.2")
     assert v.bump_prerelease().bump_build("b") == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_multiple():
     v = parse_version_info("3.4.5-rc.1+build.1")
     expected = parse_version_info("3.4.5-rc.2+build.2")
     assert v.bump_prerelease().bump_build().bump_build() == expected
+    assert v.compare(expected) == -1
     expected = parse_version_info("3.4.5-rc.3")
     assert v.bump_prerelease().bump_build().bump_build().bump_prerelease() == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_prerelease_with_empty_str():
     v = parse_version_info("3.4.5")
     expected = parse_version_info("3.4.5-1")
     assert v.bump_prerelease("") == expected
+    assert v.compare(expected) == 1
 
 
 def test_should_versioninfo_bump_prerelease_with_none():
     v = parse_version_info("3.4.5")
     expected = parse_version_info("3.4.5-rc.1")
     assert v.bump_prerelease(None) == expected
+    assert v.compare(expected) == 1
+
+
+def test_should_versioninfo_bump_prerelease_nonnumeric():
+    v = parse_version_info("3.4.5-rc1")
+    expected = parse_version_info("3.4.5-rc1.0")
+    assert v.bump_prerelease(None) == expected
+    assert v.compare(expected) == -1
+
+
+def test_should_versioninfo_bump_prerelease_nonnumeric_nine():
+    v = parse_version_info("3.4.5-rc9")
+    expected = parse_version_info("3.4.5-rc9.0")
+    assert v.bump_prerelease(None) == expected
+    assert v.compare(expected) == -1
+
+
+def test_should_versioninfo_bump_prerelease_bump_patch():
+    v = parse_version_info("3.4.5")
+    expected = parse_version_info("3.4.6-rc.1")
+    assert v.bump_prerelease(bump_when_empty=True) == expected
+    assert v.compare(expected) == -1
+
+
+def test_should_versioninfo_bump_patch_and_prerelease_bump_patch():
+    v = parse_version_info("3.4.5")
+    expected = parse_version_info("3.4.7-rc.1")
+    assert v.bump_patch().bump_prerelease(bump_when_empty=True) == expected
+    assert v.compare(expected) == -1
 
 
 def test_should_versioninfo_bump_build_with_empty_str():
     v = parse_version_info("3.4.5")
     expected = parse_version_info("3.4.5+1")
     assert v.bump_build("") == expected
+    assert v.compare(expected) == 0
 
 
 def test_should_versioninfo_bump_build_with_none():
     v = parse_version_info("3.4.5")
     expected = parse_version_info("3.4.5+build.1")
     assert v.bump_build(None) == expected
+    assert v.compare(expected) == 0
 
 
 def test_should_ignore_extensions_for_bump():
@@ -95,18 +135,18 @@ def test_should_ignore_extensions_for_bump():
 
 
 @pytest.mark.parametrize(
-    "version,token,expected",
+    "version,token,expected,expected_compare",
     [
-        ("3.4.5-rc.9", None, "3.4.5-rc.10"),
-        ("3.4.5", None, "3.4.5-rc.1"),
-        ("3.4.5", "dev", "3.4.5-dev.1"),
-        ("3.4.5", "", "3.4.5-rc.1"),
+        ("3.4.5-rc.9", None, "3.4.5-rc.10", -1),
+        ("3.4.5", None, "3.4.5-rc.1", 1),
+        ("3.4.5", "dev", "3.4.5-dev.1", 1),
+        ("3.4.5", "", "3.4.5-rc.1", 1),
     ],
 )
-def test_should_bump_prerelease(version, token, expected):
+def test_should_bump_prerelease(version, token, expected, expected_compare):
     token = "rc" if not token else token
     assert bump_prerelease(version, token) == expected
-
+    assert compare(version, expected) == expected_compare
 
 def test_should_ignore_build_on_prerelease_bump():
     assert bump_prerelease("3.4.5-rc.1+build.4") == "3.4.5-rc.2"
@@ -123,3 +163,4 @@ def test_should_ignore_build_on_prerelease_bump():
 )
 def test_should_bump_build(version, expected):
     assert bump_build(version) == expected
+    assert compare(version, expected) == 0
\ No newline at end of file
diff --git a/tests/test_pysemver-cli.py b/tests/test_pysemver-cli.py
index e783a0b..5a0a2f8 100644
--- a/tests/test_pysemver-cli.py
+++ b/tests/test_pysemver-cli.py
@@ -55,7 +55,7 @@ def test_should_parse_cli_arguments(cli, expected):
         (
             cmd_bump,
             Namespace(bump="prerelease", version="1.2.3-rc1"),
-            does_not_raise("1.2.3-rc2"),
+            does_not_raise("1.2.3-rc1.0"),
         ),
         (
             cmd_bump,