Skip to content

Commit ac1bc5f

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 <223556219+Copilot@users.noreply.github.com>
1 parent 509ef91 commit ac1bc5f

5 files changed

Lines changed: 225 additions & 6 deletions

File tree

commitizen/version_schemes.py

Lines changed: 77 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,6 +148,52 @@ def bump(
145148
VersionScheme: TypeAlias = type[VersionProtocol]
146149

147150

151+
# Custom version pattern for SemVer schemes that extends packaging's PEP 440
152+
# regex to support arbitrary semver pre-release labels (e.g., -release, -SNAPSHOT,
153+
# -pre-release). Python's packaging library does not use semver; it predates it.
154+
# We cannot fully rely on packaging.version for semver-compatible parsing.
155+
# This pattern is NOT applied to Pep440 scheme, which retains strict PEP 440 parsing.
156+
# See: https://github.com/pypa/packaging/blob/14b83e15dbb9caa87c63646ba7808b2b5e460ce6/src/packaging/version.py#L117
157+
_SEMVER_VERSION_PATTERN = r"""^\s*
158+
v?
159+
(?:
160+
(?:(?P<epoch>[0-9]+)!)? # epoch
161+
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
162+
(?P<pre> # pre-release
163+
[-_\.]?
164+
(?P<pre_l>
165+
(?! # negative lookahead to prevent
166+
[-_\.]? # matching post, rev, r, dev
167+
(post|rev|r|dev) # (reserved PEP 440 segments)
168+
[-_\.]?
169+
([0-9]+)?
170+
(\+|$)) # terminated by local segment or EOL
171+
[a-z]+(?:-[a-z]+)* # letters with optional hyphen-separated parts
172+
)
173+
[-_\.]?
174+
(?P<pre_n>[0-9]+)?
175+
)?
176+
(?P<post> # post release
177+
(?:-(?P<post_n1>[0-9]+))
178+
|
179+
(?:
180+
[-_\.]?
181+
(?P<post_l>post|rev|r)
182+
[-_\.]?
183+
(?P<post_n2>[0-9]+)?
184+
)
185+
)?
186+
(?P<dev> # dev release
187+
[-_\.]?
188+
(?P<dev_l>dev)
189+
[-_\.]?
190+
(?P<dev_n>[0-9]+)?
191+
)?
192+
)
193+
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
194+
\s*$"""
195+
196+
148197
class BaseVersion(_BaseVersion):
149198
"""
150199
A base class implementing the `VersionProtocol` for PEP440-like versions.
@@ -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,
@@ -300,6 +367,12 @@ class SemVer(BaseVersion):
300367
See: https://semver.org/spec/v1.0.0.html
301368
"""
302369

370+
# Override the PEP 440 regex to accept arbitrary semver pre-release labels
371+
# (e.g., -release, -SNAPSHOT, -pre-release). SemVer2 inherits this.
372+
_regex: re.Pattern = re.compile(
373+
_SEMVER_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE
374+
)
375+
303376
def __str__(self) -> str:
304377
parts: list[str] = []
305378

tests/test_version_scheme_pep440.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,3 +1320,21 @@ def test_pep440_scheme_property():
13201320

13211321
def test_pep440_implement_version_protocol():
13221322
assert isinstance(Pep440("0.0.1"), VersionProtocol)
1323+
1324+
1325+
def test_pep440_rejects_arbitrary_prerelease_labels():
1326+
"""Pep440 scheme must NOT accept arbitrary semver pre-release labels.
1327+
1328+
Only SemVer/SemVer2 schemes accept labels like 'release' or 'SNAPSHOT'.
1329+
This ensures the relaxed regex is scoped to SemVer schemes only.
1330+
"""
1331+
from packaging.version import InvalidVersion
1332+
1333+
with pytest.raises(InvalidVersion):
1334+
Pep440("1.0.0-release")
1335+
1336+
with pytest.raises(InvalidVersion):
1337+
Pep440("1.0.0-SNAPSHOT")
1338+
1339+
with pytest.raises(InvalidVersion):
1340+
Pep440("1.0.0-pre-release")

tests/test_version_scheme_semver.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,70 @@
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+
),
284+
# hyphenated pre-release label (issue #950)
285+
(
286+
VersionSchemeTestArgs(
287+
current_version="1.0.0-pre-release",
288+
increment="PATCH",
289+
prerelease="pre-release",
290+
prerelease_offset=0,
291+
devrelease=None,
292+
),
293+
"1.0.0-pre-release1",
294+
),
295+
# arbitrary label with local segment (lookahead fix)
296+
(
297+
VersionSchemeTestArgs(
298+
current_version="1.0.0-release+local123",
299+
increment="PATCH",
300+
prerelease="release",
301+
prerelease_offset=0,
302+
devrelease=None,
303+
),
304+
"1.0.0-release1",
305+
),
306+
# transition from arbitrary label to standard prerelease
307+
(
308+
VersionSchemeTestArgs(
309+
current_version="1.0.0-weird",
310+
increment="PATCH",
311+
prerelease="alpha",
312+
prerelease_offset=0,
313+
devrelease=None,
314+
),
315+
"1.0.0-a0",
316+
),
253317
# simple flow
254318
(
255319
VersionSchemeTestArgs(

tests/test_version_scheme_semver2.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,70 @@
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+
),
254+
(
255+
VersionSchemeTestArgs(
256+
current_version="v0.7.1-release",
257+
increment="PATCH",
258+
prerelease="release",
259+
prerelease_offset=0,
260+
devrelease=None,
261+
),
262+
"0.7.1-release.1",
263+
),
264+
(
265+
VersionSchemeTestArgs(
266+
current_version="v0.0.1-SNAPSHOT",
267+
increment="PATCH",
268+
prerelease="SNAPSHOT",
269+
prerelease_offset=0,
270+
devrelease=None,
271+
),
272+
"0.0.1-snapshot.1",
273+
),
274+
# hyphenated pre-release label (issue #950)
275+
(
276+
VersionSchemeTestArgs(
277+
current_version="1.0.0-pre-release",
278+
increment="PATCH",
279+
prerelease="pre-release",
280+
prerelease_offset=0,
281+
devrelease=None,
282+
),
283+
"1.0.0-pre-release.1",
284+
),
285+
# arbitrary label with local segment (lookahead fix)
286+
(
287+
VersionSchemeTestArgs(
288+
current_version="1.0.0-release+local123",
289+
increment="PATCH",
290+
prerelease="release",
291+
prerelease_offset=0,
292+
devrelease=None,
293+
),
294+
"1.0.0-release.1",
295+
),
296+
# transition from arbitrary label to standard prerelease
297+
(
298+
VersionSchemeTestArgs(
299+
current_version="1.0.0-weird",
300+
increment="PATCH",
301+
prerelease="alpha",
302+
prerelease_offset=0,
303+
devrelease=None,
304+
),
305+
"1.0.0-alpha.0",
306+
),
243307
# simple_flow
244308
(
245309
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)