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

1"""Module containing the commit logic.""" 

2 

3from __future__ import annotations 

4 

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 

11 

12if TYPE_CHECKING: 

13 from git_changelog.providers import ProviderRefParser, Ref 

14 from git_changelog.versioning import ParsedVersion 

15 

16 

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 

23 

24 

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 

31 

32 

33class Commit: 

34 """A class to represent a commit.""" 

35 

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. 

56 

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) 

79 

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 

90 

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 

101 

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 

106 

107 self.text_refs: dict[str, list[Ref]] = {} 

108 self.convention: dict[str, Any] = {} 

109 

110 self.trailers: dict[str, str] = {} 

111 self.body_without_trailers = self.body 

112 

113 if parse_trailers: 

114 self._parse_trailers() 

115 

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 ] 

124 

125 def update_with_convention(self, convention: CommitConvention) -> None: 

126 """Apply the convention-parsed data to this commit. 

127 

128 Arguments: 

129 convention: The convention to use. 

130 """ 

131 self.convention.update(convention.parse_commit(self)) 

132 

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. 

139 

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}" 

151 

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 ) 

159 

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) 

165 

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] 

176 

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 

183 

184 

185class CommitConvention(ABC): 

186 """A base class for a convention of commit messages.""" 

187 

188 TYPES: ClassVar[dict[str, str]] 

189 TYPE_REGEX: ClassVar[Pattern] 

190 BREAK_REGEX: ClassVar[Pattern] 

191 DEFAULT_RENDER: ClassVar[list[str]] 

192 

193 @abstractmethod 

194 def parse_commit(self, commit: Commit) -> dict[str, str | bool]: 

195 """Parse the commit to extract information. 

196 

197 Arguments: 

198 commit: The commit to parse. 

199 

200 Returns: 

201 A dictionary containing the parsed data. 

202 """ 

203 raise NotImplementedError 

204 

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()} 

220 

221 *Default sections:* 

222 

223 {default} 

224 

225 *Additional sections:* 

226 

227 {additional} 

228 """, 

229 ) 

230 

231 

232class BasicConvention(CommitConvention): 

233 """Basic commit message convention.""" 

234 

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 } 

243 

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 ] 

255 

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)) 

262 

263 return { 

264 "type": commit_type, 

265 "is_major": is_major, 

266 "is_minor": is_minor, 

267 "is_patch": is_patch, 

268 } 

269 

270 def parse_type(self, commit_subject: str) -> str: 

271 """Parse the type of the commit given its subject. 

272 

273 Arguments: 

274 commit_subject: The commit message subject. 

275 

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 "" 

283 

284 def is_minor(self, commit_type: str) -> bool: 

285 """Tell if this commit is worth a minor bump. 

286 

287 Arguments: 

288 commit_type: The commit type. 

289 

290 Returns: 

291 Whether it's a minor commit. 

292 """ 

293 return commit_type == self.TYPES["add"] 

294 

295 def is_major(self, commit_message: str) -> bool: 

296 """Tell if this commit is worth a major bump. 

297 

298 Arguments: 

299 commit_message: The commit message. 

300 

301 Returns: 

302 Whether it's a major commit. 

303 """ 

304 return bool(self.BREAK_REGEX.search(commit_message)) 

305 

306 

307class AngularConvention(CommitConvention): 

308 """Angular commit message convention.""" 

309 

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 ] 

341 

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)) 

348 

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 } 

357 

358 def parse_subject(self, commit_subject: str) -> dict[str, str]: 

359 """Parse the subject of the commit (`<type>[(scope)]: Subject`). 

360 

361 Arguments: 

362 commit_subject: The commit message subject. 

363 

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} 

373 

374 def is_minor(self, commit_type: str) -> bool: 

375 """Tell if this commit is worth a minor bump. 

376 

377 Arguments: 

378 commit_type: The commit type. 

379 

380 Returns: 

381 Whether it's a minor commit. 

382 """ 

383 return commit_type == self.TYPES["feat"] 

384 

385 def is_major(self, commit_message: str) -> bool: 

386 """Tell if this commit is worth a major bump. 

387 

388 Arguments: 

389 commit_message: The commit message. 

390 

391 Returns: 

392 Whether it's a major commit. 

393 """ 

394 return bool(self.BREAK_REGEX.search(commit_message)) 

395 

396 

397class ConventionalCommitConvention(AngularConvention): 

398 """Conventional commit message convention.""" 

399 

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 ) 

405 

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)) 

412 

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 }