Skip to content
Open
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
81 changes: 77 additions & 4 deletions commitizen/version_schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ def __ne__(self, other: object) -> bool:
def bump(
self,
increment: Increment | None,
prerelease: Prerelease | None = None,
# str instead of Prerelease to support arbitrary semver pre-release labels
# (e.g., "release", "SNAPSHOT") parsed from existing tags. The CLI still
# restricts user input to alpha/beta/rc via argparse choices.
prerelease: str | None = None,
prerelease_offset: int = 0,
devrelease: int | None = None,
is_local_version: bool = False,
Expand All @@ -145,6 +148,52 @@ def bump(
VersionScheme: TypeAlias = type[VersionProtocol]


# Custom version pattern for SemVer schemes that extends packaging's PEP 440
# regex to support arbitrary semver pre-release labels (e.g., -release, -SNAPSHOT,
# -pre-release). Python's packaging library does not use semver; it predates it.
# We cannot fully rely on packaging.version for semver-compatible parsing.
# This pattern is NOT applied to Pep440 scheme, which retains strict PEP 440 parsing.
# See: https://github.com/pypa/packaging/blob/14b83e15dbb9caa87c63646ba7808b2b5e460ce6/src/packaging/version.py#L117
_SEMVER_VERSION_PATTERN = r"""^\s*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know that, thanks for sharing.
I will take a look later.

v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>
(?! # negative lookahead to prevent
[-_\.]? # matching post, rev, r, dev
(post|rev|r|dev) # (reserved PEP 440 segments)
[-_\.]?
([0-9]+)?
(\+|$)) # terminated by local segment or EOL
[a-z]+(?:-[a-z]+)* # letters with optional hyphen-separated parts
)
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
\s*$"""


class BaseVersion(_BaseVersion):
"""
A base class implementing the `VersionProtocol` for PEP440-like versions.
Expand Down Expand Up @@ -184,8 +233,26 @@ def generate_prerelease(
# https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases
# https://semver.org/#spec-item-11
if self.is_prerelease and self.pre:
prerelease = max(prerelease, self.pre[0])
if prerelease.startswith(self.pre[0]):
current_pre_label = self.pre[0]
# packaging normalizes "alpha"→"a", "beta"→"b", "rc"→"rc"
_LABEL_TO_NORMALIZED = {"alpha": "a", "beta": "b", "rc": "rc"}
_KNOWN_PRE_LABELS = {"a", "b", "rc"}
normalized_prerelease = _LABEL_TO_NORMALIZED.get(
prerelease, prerelease.lower()
)

# The ordering logic (max) only makes sense for the known PEP 440
# labels where "a" < "b" < "rc" lexicographically. For arbitrary
# semver labels (e.g., "release", "SNAPSHOT"), we use strict equality
# since there's no defined ordering between them.
if (
current_pre_label in _KNOWN_PRE_LABELS
and normalized_prerelease in _KNOWN_PRE_LABELS
):
prerelease = max(normalized_prerelease, current_pre_label)
if prerelease == current_pre_label:
offset = self.pre[1] + 1
elif normalized_prerelease == current_pre_label:
offset = self.pre[1] + 1

return f"{prerelease}{offset}"
Expand Down Expand Up @@ -232,7 +299,7 @@ def increment_base(self, increment: Increment | None = None) -> str:
def bump(
self,
increment: Increment | None,
prerelease: Prerelease | None = None,
prerelease: str | None = None, # str to support arbitrary semver labels
prerelease_offset: int = 0,
devrelease: int | None = None,
is_local_version: bool = False,
Expand Down Expand Up @@ -300,6 +367,12 @@ class SemVer(BaseVersion):
See: https://semver.org/spec/v1.0.0.html
"""

# Override the PEP 440 regex to accept arbitrary semver pre-release labels
# (e.g., -release, -SNAPSHOT, -pre-release). SemVer2 inherits this.
_regex: re.Pattern = re.compile(
_SEMVER_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE
)

def __str__(self) -> str:
parts: list[str] = []

Expand Down
18 changes: 18 additions & 0 deletions tests/test_version_scheme_pep440.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,3 +1320,21 @@ def test_pep440_scheme_property():

def test_pep440_implement_version_protocol():
assert isinstance(Pep440("0.0.1"), VersionProtocol)


def test_pep440_rejects_arbitrary_prerelease_labels():
"""Pep440 scheme must NOT accept arbitrary semver pre-release labels.

Only SemVer/SemVer2 schemes accept labels like 'release' or 'SNAPSHOT'.
This ensures the relaxed regex is scoped to SemVer schemes only.
"""
from packaging.version import InvalidVersion

with pytest.raises(InvalidVersion):
Pep440("1.0.0-release")

with pytest.raises(InvalidVersion):
Pep440("1.0.0-SNAPSHOT")

with pytest.raises(InvalidVersion):
Pep440("1.0.0-pre-release")
64 changes: 64 additions & 0 deletions tests/test_version_scheme_semver.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,70 @@
),
"1.0.0",
),
# arbitrary semver pre-release labels (issue #950)
(
VersionSchemeTestArgs(
current_version="1.0.0-reallyweird",
increment="PATCH",
prerelease="reallyweird",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-reallyweird1",
),
(
VersionSchemeTestArgs(
current_version="v0.7.1-release",
increment="PATCH",
prerelease="release",
prerelease_offset=0,
devrelease=None,
),
"0.7.1-release1",
),
(
VersionSchemeTestArgs(
current_version="v0.0.1-SNAPSHOT",
increment="PATCH",
prerelease="SNAPSHOT",
prerelease_offset=0,
devrelease=None,
),
"0.0.1-snapshot1",
),
# hyphenated pre-release label (issue #950)
(
VersionSchemeTestArgs(
current_version="1.0.0-pre-release",
increment="PATCH",
prerelease="pre-release",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-pre-release1",
),
# arbitrary label with local segment (lookahead fix)
(
VersionSchemeTestArgs(
current_version="1.0.0-release+local123",
increment="PATCH",
prerelease="release",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-release1",
),
# transition from arbitrary label to standard prerelease
(
VersionSchemeTestArgs(
current_version="1.0.0-weird",
increment="PATCH",
prerelease="alpha",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-a0",
),
# simple flow
(
VersionSchemeTestArgs(
Expand Down
64 changes: 64 additions & 0 deletions tests/test_version_scheme_semver2.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,70 @@
),
"1.0.0",
),
# arbitrary semver pre-release labels (issue #950)
(
VersionSchemeTestArgs(
current_version="1.0.0-reallyweird",
increment="PATCH",
Comment on lines +243 to +247
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Added equivalent SemVer2 test cases for �0.7.1-release and �0.0.1-SNAPSHOT alongside the existing 1.0.0-reallyweird case.

prerelease="reallyweird",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-reallyweird.1",
),
(
VersionSchemeTestArgs(
current_version="v0.7.1-release",
increment="PATCH",
prerelease="release",
prerelease_offset=0,
devrelease=None,
),
"0.7.1-release.1",
),
(
VersionSchemeTestArgs(
current_version="v0.0.1-SNAPSHOT",
increment="PATCH",
prerelease="SNAPSHOT",
prerelease_offset=0,
devrelease=None,
),
"0.0.1-snapshot.1",
),
# hyphenated pre-release label (issue #950)
(
VersionSchemeTestArgs(
current_version="1.0.0-pre-release",
increment="PATCH",
prerelease="pre-release",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-pre-release.1",
),
# arbitrary label with local segment (lookahead fix)
(
VersionSchemeTestArgs(
current_version="1.0.0-release+local123",
increment="PATCH",
prerelease="release",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-release.1",
),
# transition from arbitrary label to standard prerelease
(
VersionSchemeTestArgs(
current_version="1.0.0-weird",
increment="PATCH",
prerelease="alpha",
prerelease_offset=0,
devrelease=None,
),
"1.0.0-alpha.0",
),
# simple_flow
(
VersionSchemeTestArgs(
Expand Down
4 changes: 2 additions & 2 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
from freezegun.api import FrozenDateTimeFactory
from pytest_mock import MockerFixture

from commitizen.version_schemes import Increment, Prerelease
from commitizen.version_schemes import Increment


class VersionSchemeTestArgs(NamedTuple):
current_version: str
increment: Increment | None
prerelease: Prerelease | None
prerelease: str | None
prerelease_offset: int
devrelease: int | None

Expand Down
Loading