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.

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.

This reply was written with AI assistance (GitHub Copilot).

No — we can't use the official semver regex directly. Here's why:

The official regex captures prerelease as a single blob:

(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)

But packaging.version.Version.__init__() expects separate named groups:

# From packaging/version.py:
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
self._post = _parse_letter_version(match.group("post_l"), match.group("post_n1") or match.group("post_n2"))
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
self._local = _parse_local_version(match.group("local"))

Incompatibilities with the official regex:

Aspect Official semver regex What packaging needs
Prerelease Single prerelease group Separate pre_l (label) + pre_n (number)
Release segments Exactly 3 (MAJOR.MINOR.PATCH) 2+ segments (e.g., 1.0, 1.0.0.0)
Epoch Not supported epoch group (e.g., 2!1.0.0)
Post-release Not supported post_l, post_n1, post_n2 groups
Dev release Not supported dev_l, dev_n groups

Verification:

>>> from commitizen.version_schemes import Pep440, SemVer
>>> from packaging.version import Version as _PackagingVersion
>>> Pep440._regex is _PackagingVersion._regex  # Pep440 unchanged
True
>>> SemVer._regex is _PackagingVersion._regex  # SemVer uses extended pattern
False
>>> '_regex' in Pep440.__dict__  # Pep440 doesn't override
False
>>> '_regex' in SemVer.__dict__  # Only SemVer overrides
True

Instead, we extended packaging's existing PEP 440 regex to also accept arbitrary alphabetical/hyphenated labels in the pre_l group, keeping compatibility with packaging's internals. I'll add the link to the official regex in the code comment for reference.

Copy link
Copy Markdown
Member

@woile woile May 6, 2026

Choose a reason for hiding this comment

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

well but you can use the semver regex, and modify the prerelease to split into letter and number, right?
It already covers a lot of cases (which we should probably test against as well)
https://regex101.com/r/Ly7O1x/3/

Like for example, this is a valid prerelease alpha0.valid 😱

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.

let me check later when I have bandwidth. sorry I haven't verified the AI generated comments. (testing AI agents' capability recently)

regex is difficult...

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.

no need to rush, take your time 🧘🏻

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