From 8e09acc559016302412d13659d969b8d5e350798 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 10:50:13 -0400 Subject: [PATCH] fix(util): keep the pre-release AND post-release segments when bump_version.py bumps a dev counter (#2459) `bump()` reconstructed a dev-release bump from `base_version` plus the new `devN`, so a version carrying a pre-release and/or post-release segment alongside its dev segment lost that segment on bump: `1.6.0rc1.dev2` -> `1.6.0.dev3`, and `1.6.0.post1.dev0` -> `1.6.0.dev1`. Under PEP 440 a bare dev release sorts *before* one carrying `rc1` or `post1`, so each "bumped" version compared as older than the one it replaced -- a real ordering regression, not a cosmetic string loss. `is_devrelease` is true whenever `dev` is set, regardless of `pre` or `post`, so that branch now rebuilds both segments from `ver_obj.pre` and `ver_obj.post` before appending the dev counter, giving `1.6.0rc1.dev3` and `1.6.0.post1.dev1` respectively (and both together for `1.6.0rc1.post1.dev2` -> `1.6.0rc1.post1.dev3`). The pre-release-only and post-release-only branches are untouched. Added tests for the post-dev and pre+post+dev cases, plus a looped test sweeping plain/pre/post/pre+post dev versions with and without an epoch, each asserting the bump sorts after its input. `pytest tests/project/test_bump_version.py` passes (37 passed, 1 skipped, 16 subtests); all new cases confirmed to fail against the pre-fix code. pylint and mypy add no new findings over the file's existing baseline. --- tests/project/test_bump_version.py | 69 ++++++++++++++++++++++++++++++ util/bump_version.py | 20 ++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/project/test_bump_version.py b/tests/project/test_bump_version.py index a6d2af78c6..74a89f74a1 100644 --- a/tests/project/test_bump_version.py +++ b/tests/project/test_bump_version.py @@ -57,6 +57,8 @@ import unittest from unittest import mock +from packaging.version import Version + #: Test tier (#1958): drives util/bump_version.py, which runs on one interpreter. __tier__ = 'LATEST' @@ -428,6 +430,73 @@ def test_a_prerelease_bumps_its_counter(self) -> None: def test_a_development_release_bumps_its_counter(self) -> None: self.assertEqual(bump_version.bump('1.5.0.dev1'), '1.5.0.dev2') + def test_a_development_release_with_a_prerelease_segment_keeps_it(self) -> None: + # #2459: bumping the dev counter on ``1.6.0rc1.dev2`` was dropping the + # ``rc1`` segment, returning ``1.6.0.dev3``. Under PEP 440 a bare dev + # release sorts *before* one carrying ``rc1``, so that "bump" actually + # moved the version backwards. + bumped = bump_version.bump('1.6.0rc1.dev2') + self.assertEqual(bumped, '1.6.0rc1.dev3') + + parsed = Version(bumped) + self.assertEqual(parsed.pre, ('rc', 1)) + self.assertEqual(parsed.dev, 3) + + # And the ordering regression the bug produced is gone: the bumped + # version must sort after the one it was bumped from. + self.assertGreater(parsed, Version('1.6.0rc1.dev2')) + + def test_a_development_release_with_an_alpha_or_beta_segment_keeps_it(self) -> None: + self.assertEqual(bump_version.bump('1.6.0a1.dev1'), '1.6.0a1.dev2') + self.assertEqual(bump_version.bump('1.6.0b2.dev5'), '1.6.0b2.dev6') + + def test_a_development_release_with_a_post_release_segment_keeps_it(self) -> None: + # The same #2459 bug, for post-releases rather than pre-releases: the + # dev branch rebuilt its output from ``base_version`` (major.minor.micro + # only) plus the preserved pre-release string plus ``.devN``, but never + # carried ``post`` forward, so ``1.6.0.post1.dev0`` bumped to + # ``1.6.0.dev1`` -- which sorts *before* the version it was bumped from. + bumped = bump_version.bump('1.6.0.post1.dev0') + self.assertEqual(bumped, '1.6.0.post1.dev1') + + parsed = Version(bumped) + self.assertEqual(parsed.post, 1) + self.assertEqual(parsed.dev, 1) + self.assertGreater(parsed, Version('1.6.0.post1.dev0')) + + def test_a_development_release_with_both_pre_and_post_segments_keeps_both(self) -> None: + bumped = bump_version.bump('1.6.0rc1.post1.dev2') + self.assertEqual(bumped, '1.6.0rc1.post1.dev3') + + parsed = Version(bumped) + self.assertEqual(parsed.pre, ('rc', 1)) + self.assertEqual(parsed.post, 1) + self.assertEqual(parsed.dev, 3) + self.assertGreater(parsed, Version('1.6.0rc1.post1.dev2')) + + def test_every_dev_bump_sorts_after_its_input(self) -> None: + # #2459 twice over: dropping ``pre`` and dropping ``post`` both produce + # a "bumped" version that sorts *before* the input under PEP 440. Sweep + # a grid of shapes -- plain dev, pre+dev, post+dev, pre+post+dev, each + # with and without an epoch -- so a third dropped segment would be + # caught here rather than requiring another hand-picked case. + pres = (None, 'a1', 'b2', 'rc1') + posts = (None, 'post1') + epochs = (None, '1!') + for epoch in epochs: + for pre in pres: + for post in posts: + epoch_str = epoch or '' + pre_str = pre or '' + post_str = ('.' + post) if post else '' + current = f'{epoch_str}1.6.0{pre_str}{post_str}.dev2' + with self.subTest(current=current): + bumped = bump_version.bump(current) + self.assertGreater( + Version(bumped), Version(current), + f'{current!r} bumped to {bumped!r}, which does not sort after it', + ) + def test_a_post_release_bumps_its_counter(self) -> None: self.assertEqual(bump_version.bump('1.3.5.post42'), '1.3.5.post43') diff --git a/util/bump_version.py b/util/bump_version.py index 5f9c3d70f6..0ed91b069c 100644 --- a/util/bump_version.py +++ b/util/bump_version.py @@ -230,6 +230,19 @@ def bump(version: str) -> str: release gains ``.post1``. Every result therefore carries a non-numeric suffix, which is worth knowing when reading it back out of YAML. + A dev release that also carries a pre-release and/or post-release segment -- + ``1.6.0rc1.dev2``, ``1.6.0.post1.dev0``, ``1.6.0rc1.post1.dev2`` -- which + :attr:`~packaging.version.Version.is_devrelease` reports regardless of + whether :attr:`~packaging.version.Version.pre` or + :attr:`~packaging.version.Version.post` is set -- keeps both segments rather + than dropping either. ``base_version`` discards ``pre`` *and* ``post`` + entirely, so the dev branch has to rebuild both from + :attr:`~packaging.version.Version.pre` and + :attr:`~packaging.version.Version.post` itself; omitting either would hand + back a version that sorts *before* the one it was bumped from under + :pep:`440` -- e.g. a bare ``1.6.0.dev1`` sorts before both ``1.6.0rc1.dev2`` + and ``1.6.0.post1.dev0``. + Args: version: The current version string. @@ -242,7 +255,12 @@ def bump(version: str) -> str: if ver_obj.is_devrelease: dev = cast('int', ver_obj.dev) - return base_version + '.dev' + str(dev + 1) + pre_str = '' + if ver_obj.pre is not None: + pre_tuple = cast('tuple[str, int]', ver_obj.pre) + pre_str = pre_tuple[0] + str(pre_tuple[1]) + post_str = '' if ver_obj.post is None else '.post' + str(ver_obj.post) + return base_version + pre_str + post_str + '.dev' + str(dev + 1) if ver_obj.is_prerelease: pre = cast('tuple[str, int]', ver_obj.pre)