diff --git a/changelog.d/pr489.bugfix.rst b/changelog.d/pr489.bugfix.rst new file mode 100644 index 00000000..0965c14c --- /dev/null +++ b/changelog.d/pr489.bugfix.rst @@ -0,0 +1,3 @@ +``Version.next_version`` now validates used prerelease tokens and matches token +families literally. Invalid tokens raise ``ValueError``. Dotted prefixes advance +correctly while existing qualifier chains and legacy counters are preserved. diff --git a/src/semver/version.py b/src/semver/version.py index 67274467..7ead382e 100644 --- a/src/semver/version.py +++ b/src/semver/version.py @@ -542,16 +542,26 @@ def next_version(self, part: str, prerelease_token: str = "rc") -> "Version": return getattr(version, "bump_" + part)() else: if version.prerelease and prerelease_token: + prerelease_token = str(prerelease_token) + cls._validate_identifiers( + prerelease_token, + cls._PRERELEASE_IDENTIFIER, + "prerelease token", + ) # Handle old-style prereleases like "rc3" (no dot separator) - match = re.match(f"^{prerelease_token}(\\d+)$", version.prerelease) + match = re.fullmatch( + rf"{re.escape(prerelease_token)}(\d+)", version.prerelease + ) if match: num = int(match.group(1)) return version.replace( prerelease=f"{prerelease_token}{num + 1}", build=None ) - current_token = version.prerelease.split(".")[0] - if current_token != prerelease_token: + if not ( + version.prerelease == prerelease_token + or version.prerelease.startswith(prerelease_token + ".") + ): return version.replace(prerelease=f"{prerelease_token}.1", build=None) return version.bump_prerelease(prerelease_token, bump_when_empty=True) diff --git a/tests/test_bump.py b/tests/test_bump.py index fcbedf4c..767d38f4 100644 --- a/tests/test_bump.py +++ b/tests/test_bump.py @@ -1,6 +1,7 @@ import pytest from semver import ( + Version, bump_build, bump_major, bump_minor, @@ -152,6 +153,52 @@ def test_should_ignore_build_on_prerelease_bump(): assert bump_prerelease("3.4.5-rc.1+build.4") == "3.4.5-rc.2" +@pytest.mark.parametrize( + "version,token,expected", + [ + ("1.2.3-alpha.beta.1+build.7", "alpha.beta", "1.2.3-alpha.beta.2"), + ("1.2.3-alpha.beta.gamma.1", "alpha", "1.2.3-alpha.beta.gamma.2"), + ("1.2.3-rc3", "rc", "1.2.3-rc4"), + ("1.2.3-rc.3", "rc", "1.2.3-rc.4"), + ("1.2.3-alphaXbeta9", "alpha.beta", "1.2.3-alpha.beta.1"), + ], +) +def test_next_version_matches_literal_prerelease_token_family( + version, token, expected +): + original = Version.parse(version) + result = original.next_version("prerelease", token) + assert str(result) == expected + assert Version.parse(str(result)).to_tuple() == result.to_tuple() + assert str(original) == version + + +@pytest.mark.parametrize( + "token", ["[", "rc.", "rc+build", "rc..next", "rc.01", "01", "ré"] +) +def test_next_version_rejects_invalid_prerelease_token(token): + with pytest.raises(ValueError): + Version.parse("1.2.3-rc.1").next_version("prerelease", token) + + +@pytest.mark.parametrize("token", [None, ""]) +def test_next_version_preserves_empty_token_semantics(token): + version = Version.parse("1.2.3-alpha.beta.1") + assert str(version.next_version("prerelease", token)) == "1.2.3-alpha.beta.2" + + +@pytest.mark.parametrize( + "part,token,expected", + [ + ("major", "rc.", "2.0.0"), + ("minor", "rc+build", "1.3.0"), + ("patch", "rc..next", "1.2.4"), + ], +) +def test_next_version_ignores_token_for_main_parts(part, token, expected): + assert str(Version.parse("1.2.3").next_version(part, token)) == expected + + @pytest.mark.parametrize( "version,expected", [ @@ -163,4 +210,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 + assert compare(version, expected) == 0