Skip to content

Commit d8813b6

Browse files
LearlojBas Roostomschr
authored
Fix #460 Improve bump_prerelease to alway get a newer version (#462)
Raising a prerelease version always results in a newer version, and raising an empty prerelease version has the option to raise the patch version as well Co-authored-by: Tom Schraitle <tomschr@users.noreply.github.com> --------- Co-authored-by: Bas Roos <bas.roos@enreach.com> Co-authored-by: Tom Schraitle <tomschr@users.noreply.github.com>
1 parent d959aa7 commit d8813b6

File tree

6 files changed

+122
-26
lines changed

6 files changed

+122
-26
lines changed

changelog.d/460.bugfix.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
:meth:`~semver.version.Version.bump_prerelease` will now add `.0` to an
2+
existing prerelease when the last segment of the current prerelease, split by
3+
dots (`.`), is not numeric. This is to ensure the new prerelease is considered
4+
higher than the previous one.
5+
6+
:meth:`~semver.version.Version.bump_prerelease` now also support an argument
7+
`bump_when_empty` which will bump the patch version if there is no existing
8+
prerelease, to ensure the resulting version is considered a higher version than
9+
the previous one.

docs/usage/raise-parts-of-a-version.rst

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ Raising Parts of a Version
33

44
.. note::
55

6-
Keep in mind, "raising" the pre-release only will make your
7-
complete version *lower* than before.
6+
Keep in mind, by default, "raising" the pre-release for a version without an existing
7+
prerelease part, only will make your complete version *lower* than before.
88

99
For example, having version ``1.0.0`` and raising the pre-release
1010
will lead to ``1.0.0-rc.1``, but ``1.0.0-rc.1`` is smaller than ``1.0.0``.
1111

12-
If you search for a way to take into account this behavior, look for the
12+
To avoid this, set `bump_when_empty=True` in the
13+
:meth:`~semver.version.Version.bump_prerelease` method, or by using the
1314
method :meth:`~semver.version.Version.next_version`
1415
in section :ref:`increase-parts-of-a-version`.
1516

@@ -67,4 +68,14 @@ is not taken into account:
6768
>>> str(Version.parse("3.4.5-rc.1").bump_prerelease(''))
6869
'3.4.5-rc.2'
6970
71+
To ensure correct ordering, we append `.0` to the last prerelease identifier
72+
if it's not numeric. This prevents cases where `rc9` would incorrectly sort
73+
lower than `rc10` (non-numeric identifiers are compared alphabetically):
74+
75+
.. code-block:: python
76+
77+
>>> str(Version.parse("3.4.5-rc9").bump_prerelease())
78+
'3.4.5-rc9.0'
79+
>>> str(Version.parse("3.4.5-rc.9").bump_prerelease())
80+
'3.4.5-rc.10'
7081

src/semver/version.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ class Version:
7777
#: The names of the different parts of a version
7878
NAMES: ClassVar[Tuple[str, ...]] = tuple([item[1:] for item in __slots__])
7979

80-
#: Regex for number in a prerelease
80+
#: Regex for number in a build
8181
_LAST_NUMBER: ClassVar[Pattern[str]] = re.compile(r"(?:[^\d]*(\d+)[^\d]*)+")
82+
#: Regex for number in a prerelease
83+
_LAST_PRERELEASE: ClassVar[Pattern[str]] = re.compile(r"^(.*\.)?(\d+)$")
8284
#: Regex template for a semver version
8385
_REGEX_TEMPLATE: ClassVar[
8486
str
@@ -245,6 +247,23 @@ def __iter__(self) -> VersionIterator:
245247
"""Return iter(self)."""
246248
yield from self.to_tuple()
247249

250+
@staticmethod
251+
def _increment_prerelease(string: str) -> str:
252+
"""
253+
Check if the last part of a dot-separated string is numeric. If yes,
254+
increase them. Else, add '.0'
255+
256+
:param string: the prerelease version to increment
257+
:return: the incremented string
258+
"""
259+
match = Version._LAST_PRERELEASE.search(string)
260+
if match:
261+
next_ = str(int(match.group(2)) + 1)
262+
string = match.group(1) + next_ if match.group(1) else next_
263+
else:
264+
string += ".0"
265+
return string
266+
248267
@staticmethod
249268
def _increment_string(string: str) -> str:
250269
"""
@@ -305,35 +324,52 @@ def bump_patch(self) -> "Version":
305324
cls = type(self)
306325
return cls(self._major, self._minor, self._patch + 1)
307326

308-
def bump_prerelease(self, token: Optional[str] = "rc") -> "Version":
327+
def bump_prerelease(
328+
self,
329+
token: Optional[str] = "rc",
330+
bump_when_empty: Optional[bool] = False
331+
) -> "Version":
309332
"""
310333
Raise the prerelease part of the version, return a new object but leave
311334
self untouched.
312335
336+
.. versionchanged:: 3.1.0
337+
Parameter `bump_when_empty` added. When set to true, bumps the patch version
338+
when called with a version that has no prerelease segment, so the return
339+
value will be considered a newer version.
340+
341+
Adds `.0` to the prerelease if the last part of the dot-separated
342+
prerelease is not a number.
343+
313344
:param token: defaults to ``'rc'``
314345
:return: new :class:`Version` object with the raised prerelease part.
315346
The original object is not modified.
316347
317348
>>> ver = semver.parse("3.4.5")
318349
>>> ver.bump_prerelease().prerelease
319-
'rc.2'
350+
'rc.1'
320351
>>> ver.bump_prerelease('').prerelease
321352
'1'
322353
>>> ver.bump_prerelease(None).prerelease
323354
'rc.1'
355+
>>> str(ver.bump_prerelease(bump_when_empty=True))
356+
'3.4.6-rc.1'
324357
"""
325358
cls = type(self)
359+
patch = self._patch
326360
if self._prerelease is not None:
327-
prerelease = self._prerelease
328-
elif token == "":
329-
prerelease = "0"
330-
elif token is None:
331-
prerelease = "rc.0"
361+
prerelease = cls._increment_prerelease(self._prerelease)
332362
else:
333-
prerelease = str(token) + ".0"
363+
if bump_when_empty:
364+
patch += 1
365+
if token == "":
366+
prerelease = "1"
367+
elif token is None:
368+
prerelease = "rc.1"
369+
else:
370+
prerelease = str(token) + ".1"
334371

335-
prerelease = cls._increment_string(prerelease)
336-
return cls(self._major, self._minor, self._patch, prerelease)
372+
return cls(self._major, self._minor, patch, prerelease)
337373

338374
def bump_build(self, token: Optional[str] = "build") -> "Version":
339375
"""
@@ -445,10 +481,8 @@ def next_version(self, part: str, prerelease_token: str = "rc") -> "Version":
445481
# Only check the main parts:
446482
if part in cls.NAMES[:3]:
447483
return getattr(version, "bump_" + part)()
448-
449-
if not version.prerelease:
450-
version = version.bump_patch()
451-
return version.bump_prerelease(prerelease_token)
484+
else:
485+
return version.bump_prerelease(prerelease_token, bump_when_empty=True)
452486

453487
@_comparator
454488
def __eq__(self, other: Comparable) -> bool: # type: ignore

tests/test_bump.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
bump_minor,
77
bump_patch,
88
bump_prerelease,
9+
compare,
910
parse_version_info,
1011
)
1112

@@ -32,81 +33,120 @@ def test_should_versioninfo_bump_minor_and_patch():
3233
v = parse_version_info("3.4.5")
3334
expected = parse_version_info("3.5.1")
3435
assert v.bump_minor().bump_patch() == expected
36+
assert v.compare(expected) == -1
3537

3638

3739
def test_should_versioninfo_bump_patch_and_prerelease():
3840
v = parse_version_info("3.4.5-rc.1")
3941
expected = parse_version_info("3.4.6-rc.1")
4042
assert v.bump_patch().bump_prerelease() == expected
43+
assert v.compare(expected) == -1
4144

4245

4346
def test_should_versioninfo_bump_patch_and_prerelease_with_token():
4447
v = parse_version_info("3.4.5-dev.1")
4548
expected = parse_version_info("3.4.6-dev.1")
4649
assert v.bump_patch().bump_prerelease("dev") == expected
50+
assert v.compare(expected) == -1
4751

4852

4953
def test_should_versioninfo_bump_prerelease_and_build():
5054
v = parse_version_info("3.4.5-rc.1+build.1")
5155
expected = parse_version_info("3.4.5-rc.2+build.2")
5256
assert v.bump_prerelease().bump_build() == expected
57+
assert v.compare(expected) == -1
5358

5459

5560
def test_should_versioninfo_bump_prerelease_and_build_with_token():
5661
v = parse_version_info("3.4.5-rc.1+b.1")
5762
expected = parse_version_info("3.4.5-rc.2+b.2")
5863
assert v.bump_prerelease().bump_build("b") == expected
64+
assert v.compare(expected) == -1
5965

6066

6167
def test_should_versioninfo_bump_multiple():
6268
v = parse_version_info("3.4.5-rc.1+build.1")
6369
expected = parse_version_info("3.4.5-rc.2+build.2")
6470
assert v.bump_prerelease().bump_build().bump_build() == expected
71+
assert v.compare(expected) == -1
6572
expected = parse_version_info("3.4.5-rc.3")
6673
assert v.bump_prerelease().bump_build().bump_build().bump_prerelease() == expected
74+
assert v.compare(expected) == -1
6775

6876

6977
def test_should_versioninfo_bump_prerelease_with_empty_str():
7078
v = parse_version_info("3.4.5")
7179
expected = parse_version_info("3.4.5-1")
7280
assert v.bump_prerelease("") == expected
81+
assert v.compare(expected) == 1
7382

7483

7584
def test_should_versioninfo_bump_prerelease_with_none():
7685
v = parse_version_info("3.4.5")
7786
expected = parse_version_info("3.4.5-rc.1")
7887
assert v.bump_prerelease(None) == expected
88+
assert v.compare(expected) == 1
89+
90+
91+
def test_should_versioninfo_bump_prerelease_nonnumeric():
92+
v = parse_version_info("3.4.5-rc1")
93+
expected = parse_version_info("3.4.5-rc1.0")
94+
assert v.bump_prerelease(None) == expected
95+
assert v.compare(expected) == -1
96+
97+
98+
def test_should_versioninfo_bump_prerelease_nonnumeric_nine():
99+
v = parse_version_info("3.4.5-rc9")
100+
expected = parse_version_info("3.4.5-rc9.0")
101+
assert v.bump_prerelease(None) == expected
102+
assert v.compare(expected) == -1
103+
104+
105+
def test_should_versioninfo_bump_prerelease_bump_patch():
106+
v = parse_version_info("3.4.5")
107+
expected = parse_version_info("3.4.6-rc.1")
108+
assert v.bump_prerelease(bump_when_empty=True) == expected
109+
assert v.compare(expected) == -1
110+
111+
112+
def test_should_versioninfo_bump_patch_and_prerelease_bump_patch():
113+
v = parse_version_info("3.4.5")
114+
expected = parse_version_info("3.4.7-rc.1")
115+
assert v.bump_patch().bump_prerelease(bump_when_empty=True) == expected
116+
assert v.compare(expected) == -1
79117

80118

81119
def test_should_versioninfo_bump_build_with_empty_str():
82120
v = parse_version_info("3.4.5")
83121
expected = parse_version_info("3.4.5+1")
84122
assert v.bump_build("") == expected
123+
assert v.compare(expected) == 0
85124

86125

87126
def test_should_versioninfo_bump_build_with_none():
88127
v = parse_version_info("3.4.5")
89128
expected = parse_version_info("3.4.5+build.1")
90129
assert v.bump_build(None) == expected
130+
assert v.compare(expected) == 0
91131

92132

93133
def test_should_ignore_extensions_for_bump():
94134
assert bump_patch("3.4.5-rc1+build4") == "3.4.6"
95135

96136

97137
@pytest.mark.parametrize(
98-
"version,token,expected",
138+
"version,token,expected,expected_compare",
99139
[
100-
("3.4.5-rc.9", None, "3.4.5-rc.10"),
101-
("3.4.5", None, "3.4.5-rc.1"),
102-
("3.4.5", "dev", "3.4.5-dev.1"),
103-
("3.4.5", "", "3.4.5-rc.1"),
140+
("3.4.5-rc.9", None, "3.4.5-rc.10", -1),
141+
("3.4.5", None, "3.4.5-rc.1", 1),
142+
("3.4.5", "dev", "3.4.5-dev.1", 1),
143+
("3.4.5", "", "3.4.5-rc.1", 1),
104144
],
105145
)
106-
def test_should_bump_prerelease(version, token, expected):
146+
def test_should_bump_prerelease(version, token, expected, expected_compare):
107147
token = "rc" if not token else token
108148
assert bump_prerelease(version, token) == expected
109-
149+
assert compare(version, expected) == expected_compare
110150

111151
def test_should_ignore_build_on_prerelease_bump():
112152
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():
123163
)
124164
def test_should_bump_build(version, expected):
125165
assert bump_build(version) == expected
166+
assert compare(version, expected) == 0

tests/test_pysemver-cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def test_should_parse_cli_arguments(cli, expected):
5555
(
5656
cmd_bump,
5757
Namespace(bump="prerelease", version="1.2.3-rc1"),
58-
does_not_raise("1.2.3-rc2"),
58+
does_not_raise("1.2.3-rc1.0"),
5959
),
6060
(
6161
cmd_bump,

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ deps =
2929
setuptools-scm
3030
setenv =
3131
PIP_DISABLE_PIP_VERSION_CHECK = 1
32+
downloads = true
3233

3334

3435
[testenv:mypy]

0 commit comments

Comments
 (0)