Skip to content

Commit 9644e1a

Browse files
committed
feat(bump_rule): add BumpRule, VersionIncrement, Prerelease Enum
1 parent 9f3ec86 commit 9644e1a

File tree

16 files changed

+1632
-695
lines changed

16 files changed

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

0 commit comments

Comments
 (0)