Skip to content

Fix #460 Improve bump_prerelease to alway get a newer version #462

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/460.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 14 additions & 3 deletions docs/usage/raise-parts-of-a-version.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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'

64 changes: 49 additions & 15 deletions src/semver/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -245,6 +247,23 @@ def __iter__(self) -> VersionIterator:
"""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:
"""
Expand Down Expand Up @@ -305,35 +324,52 @@ def bump_patch(self) -> "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":
"""
Expand Down Expand Up @@ -445,10 +481,8 @@ def next_version(self, part: str, prerelease_token: str = "rc") -> "Version":
# 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
Expand Down
55 changes: 48 additions & 7 deletions tests/test_bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
bump_minor,
bump_patch,
bump_prerelease,
compare,
parse_version_info,
)

Expand All @@ -32,81 +33,120 @@ 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():
assert bump_patch("3.4.5-rc1+build4") == "3.4.6"


@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"
Expand All @@ -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
2 changes: 1 addition & 1 deletion tests/test_pysemver-cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ deps =
setuptools-scm
setenv =
PIP_DISABLE_PIP_VERSION_CHECK = 1
downloads = true


[testenv:mypy]
Expand Down
Loading