Skip to content

Commit 9c3421c

Browse files
committed
feat(bump_rule): add BumpRule, VersionIncrement, Prerelease Enum
Closes #129
1 parent d968f26 commit 9c3421c

14 files changed

+1345
-512
lines changed

commitizen/bump.py

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,14 @@
22

33
import os
44
import re
5-
from collections import OrderedDict
65
from collections.abc import Iterable
76
from glob import iglob
8-
from logging import getLogger
97
from string import Template
10-
from typing import cast
118

12-
from commitizen.defaults import BUMP_MESSAGE, ENCODING, MAJOR, MINOR, PATCH
9+
from commitizen.defaults import BUMP_MESSAGE, ENCODING
1310
from commitizen.exceptions import CurrentVersionNotFoundError
14-
from commitizen.git import GitCommit, smart_open
15-
from commitizen.version_schemes import Increment, Version
16-
17-
VERSION_TYPES = [None, PATCH, MINOR, MAJOR]
18-
19-
logger = getLogger("commitizen")
20-
21-
22-
def find_increment(
23-
commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict
24-
) -> Increment | None:
25-
if isinstance(increments_map, dict):
26-
increments_map = OrderedDict(increments_map)
27-
28-
# Most important cases are major and minor.
29-
# Everything else will be considered patch.
30-
select_pattern = re.compile(regex)
31-
increment: str | None = None
32-
33-
for commit in commits:
34-
for message in commit.message.split("\n"):
35-
result = select_pattern.search(message)
36-
37-
if result:
38-
found_keyword = result.group(1)
39-
new_increment = None
40-
for match_pattern in increments_map.keys():
41-
if re.match(match_pattern, found_keyword):
42-
new_increment = increments_map[match_pattern]
43-
break
44-
45-
if new_increment is None:
46-
logger.debug(
47-
f"no increment needed for '{found_keyword}' in '{message}'"
48-
)
49-
50-
if VERSION_TYPES.index(increment) < VERSION_TYPES.index(new_increment):
51-
logger.debug(
52-
f"increment detected is '{new_increment}' due to '{found_keyword}' in '{message}'"
53-
)
54-
increment = new_increment
55-
56-
if increment == MAJOR:
57-
break
58-
59-
return cast(Increment, increment)
11+
from commitizen.git import smart_open
12+
from commitizen.version_schemes import Version
6013

6114

6215
def update_version_in_files(

commitizen/bump_rule.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from collections.abc import Iterable, Mapping
5+
from enum import IntEnum, auto
6+
from functools import cached_property
7+
from typing import Callable, Protocol
8+
9+
from commitizen.exceptions import NoPatternMapError
10+
11+
12+
class VersionIncrement(IntEnum):
13+
"""An enumeration representing semantic versioning increments.
14+
15+
This class defines the three types of version increments according to semantic versioning:
16+
- PATCH: For backwards-compatible bug fixes
17+
- MINOR: For backwards-compatible functionality additions
18+
- MAJOR: For incompatible API changes
19+
"""
20+
21+
PATCH = auto()
22+
MINOR = auto()
23+
MAJOR = auto()
24+
25+
def __str__(self) -> str:
26+
return self.name
27+
28+
@classmethod
29+
def safe_cast(cls, value: object) -> VersionIncrement | None:
30+
if not isinstance(value, str):
31+
return None
32+
try:
33+
return cls[value]
34+
except KeyError:
35+
return None
36+
37+
@classmethod
38+
def safe_cast_dict(cls, d: Mapping[str, object]) -> dict[str, VersionIncrement]:
39+
return {
40+
k: v
41+
for k, v in ((k, VersionIncrement.safe_cast(v)) for k, v in d.items())
42+
if v is not None
43+
}
44+
45+
@staticmethod
46+
def get_highest_by_messages(
47+
commit_messages: Iterable[str],
48+
get_increment: Callable[[str], VersionIncrement | None],
49+
) -> VersionIncrement | None:
50+
"""Find the highest version increment from a list of messages.
51+
52+
This function processes a list of messages and determines the highest version
53+
increment needed based on the commit messages. It splits multi-line commit messages
54+
and evaluates each line using the provided get_increment callable.
55+
56+
Args:
57+
commit_messages: A list of messages to analyze.
58+
get_increment: A callable that takes a commit message string and returns an
59+
VersionIncrement value (MAJOR, MINOR, PATCH) or None if no increment is needed.
60+
61+
Returns:
62+
The highest version increment needed (MAJOR, MINOR, PATCH) or None if no
63+
increment is needed. The order of precedence is MAJOR > MINOR > PATCH.
64+
65+
Example:
66+
>>> commit_messages = ["feat: new feature", "fix: bug fix"]
67+
>>> rule = ConventionalCommitBumpRule()
68+
>>> VersionIncrement.get_highest_by_messages(commit_messages, lambda x: rule.get_increment(x, False))
69+
'MINOR'
70+
"""
71+
return VersionIncrement.get_highest(
72+
get_increment(line)
73+
for message in commit_messages
74+
for line in message.split("\n")
75+
)
76+
77+
@staticmethod
78+
def get_highest(
79+
increments: Iterable[VersionIncrement | None],
80+
) -> VersionIncrement | None:
81+
return max(filter(None, increments), default=None)
82+
83+
84+
class BumpRule(Protocol):
85+
"""A protocol defining the interface for version bump rules.
86+
87+
This protocol specifies the contract that all version bump rule implementations must follow.
88+
It defines how commit messages should be analyzed to determine the appropriate semantic
89+
version increment.
90+
91+
The protocol is used to ensure consistent behavior across different bump rule implementations,
92+
such as conventional commits or custom rules.
93+
"""
94+
95+
def get_increment(
96+
self, commit_message: str, major_version_zero: bool
97+
) -> VersionIncrement | None:
98+
"""Determine the version increment based on a commit message.
99+
100+
This method analyzes a commit message to determine what kind of version increment
101+
is needed according to the Conventional Commits specification. It handles special
102+
cases for breaking changes and respects the major_version_zero flag.
103+
104+
Args:
105+
commit_message: The commit message to analyze. Should follow conventional commit format.
106+
major_version_zero: If True, breaking changes will result in a MINOR version bump
107+
instead of MAJOR. This is useful for projects in 0.x.x versions.
108+
109+
Returns:
110+
VersionIncrement | None: The type of version increment needed:
111+
- MAJOR: For breaking changes when major_version_zero is False
112+
- MINOR: For breaking changes when major_version_zero is True, or for new features
113+
- PATCH: For bug fixes, performance improvements, or refactors
114+
- None: For commits that don't require a version bump (docs, style, etc.)
115+
"""
116+
117+
118+
class ConventionalCommitBumpRule(BumpRule):
119+
_BREAKING_CHANGE_TYPES = set(["BREAKING CHANGE", "BREAKING-CHANGE"])
120+
_MINOR_CHANGE_TYPES = set(["feat"])
121+
_PATCH_CHANGE_TYPES = set(["fix", "perf", "refactor"])
122+
123+
def get_increment(
124+
self, commit_message: str, major_version_zero: bool
125+
) -> VersionIncrement | None:
126+
if not (m := self._head_pattern.match(commit_message)):
127+
return None
128+
129+
change_type = m.group("change_type")
130+
if m.group("bang") or change_type in self._BREAKING_CHANGE_TYPES:
131+
return (
132+
VersionIncrement.MINOR if major_version_zero else VersionIncrement.MAJOR
133+
)
134+
135+
if change_type in self._MINOR_CHANGE_TYPES:
136+
return VersionIncrement.MINOR
137+
138+
if change_type in self._PATCH_CHANGE_TYPES:
139+
return VersionIncrement.PATCH
140+
141+
return None
142+
143+
@cached_property
144+
def _head_pattern(self) -> re.Pattern:
145+
change_types = [
146+
*self._BREAKING_CHANGE_TYPES,
147+
*self._PATCH_CHANGE_TYPES,
148+
*self._MINOR_CHANGE_TYPES,
149+
"docs",
150+
"style",
151+
"test",
152+
"build",
153+
"ci",
154+
]
155+
re_change_type = r"(?P<change_type>" + "|".join(change_types) + r")"
156+
re_scope = r"(?P<scope>\(.+\))?"
157+
re_bang = r"(?P<bang>!)?"
158+
return re.compile(f"^{re_change_type}{re_scope}{re_bang}:")
159+
160+
161+
class CustomBumpRule(BumpRule):
162+
def __init__(
163+
self,
164+
bump_pattern: str,
165+
bump_map: Mapping[str, VersionIncrement],
166+
bump_map_major_version_zero: Mapping[str, VersionIncrement],
167+
) -> None:
168+
"""Initialize a custom bump rule for version incrementing.
169+
170+
This constructor creates a rule that determines how version numbers should be
171+
incremented based on commit messages. It validates and compiles the provided
172+
pattern and maps for use in version bumping.
173+
174+
The fallback logic is used for backward compatibility.
175+
176+
Args:
177+
bump_pattern: A regex pattern string used to match commit messages.
178+
Example: r"^((?P<major>major)|(?P<minor>minor)|(?P<patch>patch))(?P<scope>\(.+\))?(?P<bang>!)?:"
179+
Or with fallback regex: r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):" # First group is type
180+
bump_map: A mapping of commit types to their corresponding version increments.
181+
Example: {
182+
"major": VersionIncrement.MAJOR,
183+
"bang": VersionIncrement.MAJOR,
184+
"minor": VersionIncrement.MINOR,
185+
"patch": VersionIncrement.PATCH
186+
}
187+
Or with fallback: {
188+
(r"^.+!$", VersionIncrement.MAJOR),
189+
(r"^BREAKING[\-\ ]CHANGE", VersionIncrement.MAJOR),
190+
(r"^feat", VersionIncrement.MINOR),
191+
(r"^fix", VersionIncrement.PATCH),
192+
(r"^refactor", VersionIncrement.PATCH),
193+
(r"^perf", VersionIncrement.PATCH),
194+
}
195+
bump_map_major_version_zero: A mapping of commit types to version increments
196+
specifically for when the major version is 0. This allows for different
197+
versioning behavior during initial development.
198+
The format is the same as bump_map.
199+
Example: {
200+
"major": VersionIncrement.MINOR, # MAJOR becomes MINOR in version zero
201+
"bang": VersionIncrement.MINOR, # Breaking changes become MINOR in version zero
202+
"minor": VersionIncrement.MINOR,
203+
"patch": VersionIncrement.PATCH
204+
}
205+
Or with fallback: {
206+
(r"^.+!$", VersionIncrement.MINOR),
207+
(r"^BREAKING[\-\ ]CHANGE", VersionIncrement.MINOR),
208+
(r"^feat", VersionIncrement.MINOR),
209+
(r"^fix", VersionIncrement.PATCH),
210+
(r"^refactor", VersionIncrement.PATCH),
211+
(r"^perf", VersionIncrement.PATCH),
212+
}
213+
214+
Raises:
215+
NoPatternMapError: If any of the required parameters are empty or None
216+
"""
217+
if not bump_map or not bump_pattern or not bump_map_major_version_zero:
218+
raise NoPatternMapError(
219+
f"Invalid bump rule: {bump_pattern=} and {bump_map=} and {bump_map_major_version_zero=}"
220+
)
221+
222+
self.bump_pattern = re.compile(bump_pattern)
223+
self.bump_map = bump_map
224+
self.bump_map_major_version_zero = bump_map_major_version_zero
225+
226+
def get_increment(
227+
self, commit_message: str, major_version_zero: bool
228+
) -> VersionIncrement | None:
229+
if not (m := self.bump_pattern.search(commit_message)):
230+
return None
231+
232+
effective_bump_map = (
233+
self.bump_map_major_version_zero if major_version_zero else self.bump_map
234+
)
235+
236+
try:
237+
if ret := VersionIncrement.get_highest(
238+
(
239+
increment
240+
for name, increment in effective_bump_map.items()
241+
if m.group(name)
242+
),
243+
):
244+
return ret
245+
except IndexError:
246+
pass
247+
248+
# Fallback to legacy bump rule, for backward compatibility
249+
found_keyword = m.group(1)
250+
for match_pattern, increment in effective_bump_map.items():
251+
if re.match(match_pattern, found_keyword):
252+
return increment
253+
return None

0 commit comments

Comments
 (0)