Coverage for src/git_changelog/commit.py: 89.13%
166 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-02 00:26 +0200
1"""Module containing the commit logic."""
3from __future__ import annotations
5import re
6from abc import ABC, abstractmethod
7from collections import defaultdict
8from contextlib import suppress
9from datetime import datetime, timezone
10from typing import TYPE_CHECKING, Any, Callable, ClassVar, Pattern
12if TYPE_CHECKING:
13 from git_changelog.providers import ProviderRefParser, Ref
14 from git_changelog.versioning import ParsedVersion
17def _clean_body(lines: list[str]) -> list[str]:
18 while lines and not lines[0].strip():
19 lines.pop(0)
20 while lines and not lines[-1].strip():
21 lines.pop()
22 return lines
25def _is_valid_version(version: str, version_parser: Callable[[str], tuple[ParsedVersion, str]]) -> bool:
26 try:
27 version_parser(version)
28 except ValueError:
29 return False
30 return True
33class Commit:
34 """A class to represent a commit."""
36 def __init__(
37 self,
38 commit_hash: str,
39 author_name: str = "",
40 author_email: str = "",
41 author_date: str | datetime = "",
42 committer_name: str = "",
43 committer_email: str = "",
44 committer_date: str | datetime = "",
45 refs: str = "",
46 subject: str = "",
47 body: list[str] | None = None,
48 url: str = "",
49 *,
50 parse_trailers: bool = False,
51 parent_hashes: str | list[str] = "",
52 commits_map: dict[str, Commit] | None = None,
53 version_parser: Callable[[str], tuple[ParsedVersion, str]] | None = None,
54 ):
55 """Initialization method.
57 Arguments:
58 commit_hash: The commit hash.
59 author_name: The author name.
60 author_email: The author email.
61 author_date: The authoring date (datetime or UTC timestamp).
62 committer_name: The committer name.
63 committer_email: The committer email.
64 committer_date: The committing date (datetime or UTC timestamp).
65 refs: The commit refs.
66 subject: The commit message subject.
67 body: The commit message body.
68 url: The commit URL.
69 parse_trailers: Whether to parse Git trailers.
70 """
71 if not author_date:
72 author_date = datetime.now() # noqa: DTZ005
73 elif isinstance(author_date, str): 73 ↛ 75line 73 didn't jump to line 75, because the condition on line 73 was never false
74 author_date = datetime.fromtimestamp(float(author_date), tz=timezone.utc)
75 if not committer_date:
76 committer_date = datetime.now() # noqa: DTZ005
77 elif isinstance(committer_date, str): 77 ↛ 80line 77 didn't jump to line 80, because the condition on line 77 was never false
78 committer_date = datetime.fromtimestamp(float(committer_date), tz=timezone.utc)
80 self.hash: str = commit_hash
81 self.author_name: str = author_name
82 self.author_email: str = author_email
83 self.author_date: datetime = author_date
84 self.committer_name: str = committer_name
85 self.committer_email: str = committer_email
86 self.committer_date: datetime = committer_date
87 self.subject: str = subject
88 self.body: list[str] = _clean_body(body) if body else []
89 self.url: str = url
91 tag = ""
92 for ref in refs.split(","):
93 ref = ref.strip() # noqa: PLW2901
94 if ref.startswith("tag: "):
95 ref = ref.replace("tag: ", "") # noqa: PLW2901
96 if version_parser is None or _is_valid_version(ref, version_parser):
97 tag = ref
98 break
99 self.tag: str = tag
100 self.version: str = tag
102 if isinstance(parent_hashes, str): 102 ↛ 104line 102 didn't jump to line 104, because the condition on line 102 was never false
103 parent_hashes = parent_hashes.split()
104 self.parent_hashes = parent_hashes
105 self._commits_map = commits_map
107 self.text_refs: dict[str, list[Ref]] = {}
108 self.convention: dict[str, Any] = {}
110 self.trailers: dict[str, str] = {}
111 self.body_without_trailers = self.body
113 if parse_trailers:
114 self._parse_trailers()
116 @property
117 def parent_commits(self) -> list[Commit]:
118 """Parent commits of this commit."""
119 if not self._commits_map: 119 ↛ 120line 119 didn't jump to line 120, because the condition on line 119 was never true
120 return []
121 return [
122 self._commits_map[parent_hash] for parent_hash in self.parent_hashes if parent_hash in self._commits_map
123 ]
125 def update_with_convention(self, convention: CommitConvention) -> None:
126 """Apply the convention-parsed data to this commit.
128 Arguments:
129 convention: The convention to use.
130 """
131 self.convention.update(convention.parse_commit(self))
133 def update_with_provider(
134 self,
135 provider: ProviderRefParser,
136 parse_refs: bool = True, # noqa: FBT001,FBT002
137 ) -> None:
138 """Apply the provider-parsed data to this commit.
140 Arguments:
141 provider: The provider to use.
142 parse_refs: Whether to parse references for this provider.
143 """
144 # set the commit url based on provider
145 # FIXME: hardcoded 'commits'
146 if "commits" in provider.REF: 146 ↛ 150line 146 didn't jump to line 150, because the condition on line 146 was never false
147 self.url = provider.build_ref_url("commits", {"ref": self.hash})
148 else:
149 # use default "commit" url (could be wrong)
150 self.url = f"{provider.url}/{provider.namespace}/{provider.project}/commit/{self.hash}"
152 # build commit text references from its subject and body
153 if parse_refs: 153 ↛ 154line 153 didn't jump to line 154, because the condition on line 153 was never true
154 for ref_type in provider.REF:
155 self.text_refs[ref_type] = provider.get_refs(
156 ref_type,
157 "\n".join([self.subject, *self.body]),
158 )
160 if "issues" in self.text_refs:
161 self.text_refs["issues_not_in_subject"] = []
162 for issue in self.text_refs["issues"]:
163 if issue.ref not in self.subject:
164 self.text_refs["issues_not_in_subject"].append(issue)
166 def _parse_trailers(self) -> None:
167 last_blank_line = -1
168 for index, line in enumerate(self.body):
169 if not line:
170 last_blank_line = index
171 with suppress(ValueError):
172 trailers = self._parse_trailers_block(self.body[last_blank_line + 1 :])
173 if trailers: 173 ↛ 171line 173 didn't jump to line 171
174 self.trailers.update(trailers)
175 self.body_without_trailers = self.body[:last_blank_line]
177 def _parse_trailers_block(self, lines: list[str]) -> dict[str, str]:
178 trailers = {}
179 for line in lines:
180 title, value = line.split(": ", 1)
181 trailers[title] = value.strip()
182 return trailers # or raise ValueError due to split unpacking
185class CommitConvention(ABC):
186 """A base class for a convention of commit messages."""
188 TYPES: ClassVar[dict[str, str]]
189 TYPE_REGEX: ClassVar[Pattern]
190 BREAK_REGEX: ClassVar[Pattern]
191 DEFAULT_RENDER: ClassVar[list[str]]
193 @abstractmethod
194 def parse_commit(self, commit: Commit) -> dict[str, str | bool]:
195 """Parse the commit to extract information.
197 Arguments:
198 commit: The commit to parse.
200 Returns:
201 A dictionary containing the parsed data.
202 """
203 raise NotImplementedError
205 @classmethod
206 def _format_sections_help(cls) -> str:
207 reversed_map = defaultdict(list)
208 for section_type, section_title in cls.TYPES.items():
209 reversed_map[section_title].append(section_type)
210 default_sections = cls.DEFAULT_RENDER
211 default = "- " + "\n- ".join(f"{', '.join(reversed_map[title])}: {title}" for title in default_sections)
212 additional = "- " + "\n- ".join(
213 f"{', '.join(types)}: {title}" for title, types in reversed_map.items() if title not in default_sections
214 )
215 return re.sub(
216 r"\n *",
217 "\n",
218 f"""
219 #### {cls.__name__[:-(len('Convention'))].strip()}
221 *Default sections:*
223 {default}
225 *Additional sections:*
227 {additional}
228 """,
229 )
232class BasicConvention(CommitConvention):
233 """Basic commit message convention."""
235 TYPES: ClassVar[dict[str, str]] = {
236 "add": "Added",
237 "fix": "Fixed",
238 "change": "Changed",
239 "remove": "Removed",
240 "merge": "Merged",
241 "doc": "Documented",
242 }
244 TYPE_REGEX: ClassVar[Pattern] = re.compile(r"^(?P<type>(%s))" % "|".join(TYPES.keys()), re.I)
245 BREAK_REGEX: ClassVar[Pattern] = re.compile(
246 r"^break(s|ing changes?)?[ :].+$",
247 re.I | re.MULTILINE,
248 )
249 DEFAULT_RENDER: ClassVar[list[str]] = [
250 TYPES["add"],
251 TYPES["fix"],
252 TYPES["change"],
253 TYPES["remove"],
254 ]
256 def parse_commit(self, commit: Commit) -> dict[str, str | bool]: # noqa: D102
257 commit_type = self.parse_type(commit.subject)
258 message = "\n".join([commit.subject, *commit.body])
259 is_major = self.is_major(message)
260 is_minor = not is_major and self.is_minor(commit_type)
261 is_patch = not any((is_major, is_minor))
263 return {
264 "type": commit_type,
265 "is_major": is_major,
266 "is_minor": is_minor,
267 "is_patch": is_patch,
268 }
270 def parse_type(self, commit_subject: str) -> str:
271 """Parse the type of the commit given its subject.
273 Arguments:
274 commit_subject: The commit message subject.
276 Returns:
277 The commit type.
278 """
279 type_match = self.TYPE_REGEX.match(commit_subject)
280 if type_match:
281 return self.TYPES.get(type_match.groupdict()["type"].lower(), "")
282 return ""
284 def is_minor(self, commit_type: str) -> bool:
285 """Tell if this commit is worth a minor bump.
287 Arguments:
288 commit_type: The commit type.
290 Returns:
291 Whether it's a minor commit.
292 """
293 return commit_type == self.TYPES["add"]
295 def is_major(self, commit_message: str) -> bool:
296 """Tell if this commit is worth a major bump.
298 Arguments:
299 commit_message: The commit message.
301 Returns:
302 Whether it's a major commit.
303 """
304 return bool(self.BREAK_REGEX.search(commit_message))
307class AngularConvention(CommitConvention):
308 """Angular commit message convention."""
310 TYPES: ClassVar[dict[str, str]] = {
311 "build": "Build",
312 "chore": "Chore",
313 "ci": "Continuous Integration",
314 "deps": "Dependencies",
315 "doc": "Docs",
316 "docs": "Docs",
317 "feat": "Features",
318 "fix": "Bug Fixes",
319 "perf": "Performance Improvements",
320 "ref": "Code Refactoring",
321 "refactor": "Code Refactoring",
322 "revert": "Reverts",
323 "style": "Style",
324 "test": "Tests",
325 "tests": "Tests",
326 }
327 SUBJECT_REGEX: ClassVar[Pattern] = re.compile(
328 r"^(?P<type>(%s))(?:\((?P<scope>.+)\))?: (?P<subject>.+)$" % ("|".join(TYPES.keys())), # (%)
329 )
330 BREAK_REGEX: ClassVar[Pattern] = re.compile(
331 r"^break(s|ing changes?)?[ :].+$",
332 re.I | re.MULTILINE,
333 )
334 DEFAULT_RENDER: ClassVar[list[str]] = [
335 TYPES["feat"],
336 TYPES["fix"],
337 TYPES["revert"],
338 TYPES["refactor"],
339 TYPES["perf"],
340 ]
342 def parse_commit(self, commit: Commit) -> dict[str, str | bool]: # noqa: D102
343 subject = self.parse_subject(commit.subject)
344 message = "\n".join([commit.subject, *commit.body])
345 is_major = self.is_major(message)
346 is_minor = not is_major and self.is_minor(subject["type"])
347 is_patch = not any((is_major, is_minor))
349 return {
350 "type": subject["type"],
351 "scope": subject["scope"],
352 "subject": subject["subject"],
353 "is_major": is_major,
354 "is_minor": is_minor,
355 "is_patch": is_patch,
356 }
358 def parse_subject(self, commit_subject: str) -> dict[str, str]:
359 """Parse the subject of the commit (`<type>[(scope)]: Subject`).
361 Arguments:
362 commit_subject: The commit message subject.
364 Returns:
365 The parsed data.
366 """
367 subject_match = self.SUBJECT_REGEX.match(commit_subject)
368 if subject_match:
369 dct = subject_match.groupdict()
370 dct["type"] = self.TYPES[dct["type"]]
371 return dct
372 return {"type": "", "scope": "", "subject": commit_subject}
374 def is_minor(self, commit_type: str) -> bool:
375 """Tell if this commit is worth a minor bump.
377 Arguments:
378 commit_type: The commit type.
380 Returns:
381 Whether it's a minor commit.
382 """
383 return commit_type == self.TYPES["feat"]
385 def is_major(self, commit_message: str) -> bool:
386 """Tell if this commit is worth a major bump.
388 Arguments:
389 commit_message: The commit message.
391 Returns:
392 Whether it's a major commit.
393 """
394 return bool(self.BREAK_REGEX.search(commit_message))
397class ConventionalCommitConvention(AngularConvention):
398 """Conventional commit message convention."""
400 TYPES: ClassVar[dict[str, str]] = AngularConvention.TYPES
401 DEFAULT_RENDER: ClassVar[list[str]] = AngularConvention.DEFAULT_RENDER
402 SUBJECT_REGEX: ClassVar[Pattern] = re.compile(
403 r"^(?P<type>(%s))(?:\((?P<scope>.+)\))?(?P<breaking>!)?: (?P<subject>.+)$" % ("|".join(TYPES.keys())), # (%)
404 )
406 def parse_commit(self, commit: Commit) -> dict[str, str | bool]: # noqa: D102
407 subject = self.parse_subject(commit.subject)
408 message = "\n".join([commit.subject, *commit.body])
409 is_major = self.is_major(message) or subject.get("breaking") == "!"
410 is_minor = not is_major and self.is_minor(subject["type"])
411 is_patch = not any((is_major, is_minor))
413 return {
414 "type": subject["type"],
415 "scope": subject["scope"],
416 "subject": subject["subject"],
417 "is_major": is_major,
418 "is_minor": is_minor,
419 "is_patch": is_patch,
420 }