Skip to content

Commit 8a6c84f

Browse files
committed
feat(bump_rule): add BumpRule, VersionIncrement, Prerelease Enum
Closes #129
1 parent 6b4f8b0 commit 8a6c84f

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy