Skip to content

Commit 9fff07f

Browse files
fix(version_schemes): support arbitrary semver pre-release labels
Extend BaseVersion with a custom _VERSION_PATTERN regex that accepts arbitrary pre-release identifiers (e.g., -release, -SNAPSHOT, -reallyweird) instead of only PEP 440's alpha/beta/rc. This fixes InvalidVersion errors when using tags like v0.7.1-release or v0.0.1-SNAPSHOT with commitizen's changelog and bump commands. Closes #950 Co-authored-by: Copilot <[email protected]>
1 parent 509ef91 commit 9fff07f

4 files changed

Lines changed: 115 additions & 6 deletions

File tree

commitizen/version_schemes.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ def __ne__(self, other: object) -> bool:
121121
def bump(
122122
self,
123123
increment: Increment | None,
124-
prerelease: Prerelease | None = None,
124+
# str instead of Prerelease to support arbitrary semver pre-release labels
125+
# (e.g., "release", "SNAPSHOT") parsed from existing tags. The CLI still
126+
# restricts user input to alpha/beta/rc via argparse choices.
127+
prerelease: str | None = None,
125128
prerelease_offset: int = 0,
126129
devrelease: int | None = None,
127130
is_local_version: bool = False,
@@ -145,11 +148,57 @@ def bump(
145148
VersionScheme: TypeAlias = type[VersionProtocol]
146149

147150

151+
# Custom version pattern that extends packaging's PEP 440 regex to support
152+
# arbitrary semver pre-release labels (e.g., -release, -SNAPSHOT, -reallyweird).
153+
# Python's packaging library does not use semver; it predates it. We cannot fully
154+
# rely on packaging.version for semver-compatible parsing.
155+
# See: https://github.com/pypa/packaging/blob/14b83e15dbb9caa87c63646ba7808b2b5e460ce6/src/packaging/version.py#L117
156+
_VERSION_PATTERN = r"""^\s*
157+
v?
158+
(?:
159+
(?:(?P<epoch>[0-9]+)!)? # epoch
160+
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
161+
(?P<pre> # pre-release
162+
[-_\.]?
163+
(?P<pre_l>
164+
(?! # negative lookahead to prevent
165+
[-_\.]? # matching post, rev, r, dev
166+
(post|rev|r|dev)
167+
[-_\.]?
168+
([0-9]+)?
169+
$)
170+
[a-z]+? # match any letters (semver support)
171+
)
172+
[-_\.]?
173+
(?P<pre_n>[0-9]+)?
174+
)?
175+
(?P<post> # post release
176+
(?:-(?P<post_n1>[0-9]+))
177+
|
178+
(?:
179+
[-_\.]?
180+
(?P<post_l>post|rev|r)
181+
[-_\.]?
182+
(?P<post_n2>[0-9]+)?
183+
)
184+
)?
185+
(?P<dev> # dev release
186+
[-_\.]?
187+
(?P<dev_l>dev)
188+
[-_\.]?
189+
(?P<dev_n>[0-9]+)?
190+
)?
191+
)
192+
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
193+
\s*$"""
194+
195+
148196
class BaseVersion(_BaseVersion):
149197
"""
150198
A base class implementing the `VersionProtocol` for PEP440-like versions.
151199
"""
152200

201+
_regex: re.Pattern = re.compile(_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE)
153202
parser: ClassVar[re.Pattern] = _DEFAULT_VERSION_PARSER
154203
"""Regex capturing this version scheme into a `version` group"""
155204

@@ -184,8 +233,26 @@ def generate_prerelease(
184233
# https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases
185234
# https://semver.org/#spec-item-11
186235
if self.is_prerelease and self.pre:
187-
prerelease = max(prerelease, self.pre[0])
188-
if prerelease.startswith(self.pre[0]):
236+
current_pre_label = self.pre[0]
237+
# packaging normalizes "alpha"→"a", "beta"→"b", "rc"→"rc"
238+
_LABEL_TO_NORMALIZED = {"alpha": "a", "beta": "b", "rc": "rc"}
239+
_KNOWN_PRE_LABELS = {"a", "b", "rc"}
240+
normalized_prerelease = _LABEL_TO_NORMALIZED.get(
241+
prerelease, prerelease.lower()
242+
)
243+
244+
# The ordering logic (max) only makes sense for the known PEP 440
245+
# labels where "a" < "b" < "rc" lexicographically. For arbitrary
246+
# semver labels (e.g., "release", "SNAPSHOT"), we use strict equality
247+
# since there's no defined ordering between them.
248+
if (
249+
current_pre_label in _KNOWN_PRE_LABELS
250+
and normalized_prerelease in _KNOWN_PRE_LABELS
251+
):
252+
prerelease = max(normalized_prerelease, current_pre_label)
253+
if prerelease == current_pre_label:
254+
offset = self.pre[1] + 1
255+
elif normalized_prerelease == current_pre_label:
189256
offset = self.pre[1] + 1
190257

191258
return f"{prerelease}{offset}"
@@ -232,7 +299,7 @@ def increment_base(self, increment: Increment | None = None) -> str:
232299
def bump(
233300
self,
234301
increment: Increment | None,
235-
prerelease: Prerelease | None = None,
302+
prerelease: str | None = None, # str to support arbitrary semver labels
236303
prerelease_offset: int = 0,
237304
devrelease: int | None = None,
238305
is_local_version: bool = False,

tests/test_version_scheme_semver.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,37 @@
250250
),
251251
"1.0.0",
252252
),
253+
# arbitrary semver pre-release labels (issue #950)
254+
(
255+
VersionSchemeTestArgs(
256+
current_version="1.0.0-reallyweird",
257+
increment="PATCH",
258+
prerelease="reallyweird",
259+
prerelease_offset=0,
260+
devrelease=None,
261+
),
262+
"1.0.0-reallyweird1",
263+
),
264+
(
265+
VersionSchemeTestArgs(
266+
current_version="v0.7.1-release",
267+
increment="PATCH",
268+
prerelease="release",
269+
prerelease_offset=0,
270+
devrelease=None,
271+
),
272+
"0.7.1-release1",
273+
),
274+
(
275+
VersionSchemeTestArgs(
276+
current_version="v0.0.1-SNAPSHOT",
277+
increment="PATCH",
278+
prerelease="SNAPSHOT",
279+
prerelease_offset=0,
280+
devrelease=None,
281+
),
282+
"0.0.1-snapshot1",
283+
),
253284
# simple flow
254285
(
255286
VersionSchemeTestArgs(

tests/test_version_scheme_semver2.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@
240240
),
241241
"1.0.0",
242242
),
243+
# arbitrary semver pre-release labels (issue #950)
244+
(
245+
VersionSchemeTestArgs(
246+
current_version="1.0.0-reallyweird",
247+
increment="PATCH",
248+
prerelease="reallyweird",
249+
prerelease_offset=0,
250+
devrelease=None,
251+
),
252+
"1.0.0-reallyweird.1",
253+
),
243254
# simple_flow
244255
(
245256
VersionSchemeTestArgs(

tests/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
from freezegun.api import FrozenDateTimeFactory
1919
from pytest_mock import MockerFixture
2020

21-
from commitizen.version_schemes import Increment, Prerelease
21+
from commitizen.version_schemes import Increment
2222

2323

2424
class VersionSchemeTestArgs(NamedTuple):
2525
current_version: str
2626
increment: Increment | None
27-
prerelease: Prerelease | None
27+
prerelease: str | None
2828
prerelease_offset: int
2929
devrelease: int | None
3030

0 commit comments

Comments
 (0)