Coverage for src/git_changelog/build.py: 88.27%

213 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-04 17:35 +0200

1"""The module responsible for building the data.""" 

2 

3from __future__ import annotations 

4 

5import datetime 

6import os 

7import sys 

8import warnings 

9from subprocess import CalledProcessError, check_output 

10from typing import TYPE_CHECKING, ClassVar, Literal, Type, Union 

11from urllib.parse import urlsplit, urlunsplit 

12 

13from git_changelog.commit import ( 

14 AngularConvention, 

15 BasicConvention, 

16 Commit, 

17 CommitConvention, 

18 ConventionalCommitConvention, 

19) 

20from git_changelog.providers import Bitbucket, GitHub, GitLab, ProviderRefParser 

21from git_changelog.versioning import ParsedVersion, bump_pep440, bump_semver, parse_pep440, parse_semver 

22 

23if TYPE_CHECKING: 

24 from pathlib import Path 

25 

26 from git_changelog.versioning import SemVerVersion 

27 

28ConventionType = Union[str, CommitConvention, Type[CommitConvention]] 

29 

30 

31# TODO: Remove at some point. 

32def bump(version: str, part: Literal["major", "minor", "patch"] = "patch", *, zerover: bool = True) -> str: 

33 """Bump a version. Deprecated, use [`bump_semver`][git_changelog.versioning.bump_semver] instead. 

34 

35 Arguments: 

36 version: The version to bump. 

37 part: The part of the version to bump (major, minor, or patch). 

38 zerover: Keep major version at zero, even for breaking changes. 

39 

40 Returns: 

41 The bumped version. 

42 """ 

43 warnings.warn( 

44 "This function is deprecated in favor of `git_changelog.versioning.bump_semver`", 

45 DeprecationWarning, 

46 stacklevel=2, 

47 ) 

48 return bump_semver(version, part, zerover=zerover) 

49 

50 

51# TODO: Remove at some point. 

52def parse_version(version: str) -> tuple[SemVerVersion, str]: 

53 """Parse a version. Deprecated, use [`bump_semver`][git_changelog.versioning.parse_semver] instead. 

54 

55 Arguments: 

56 version: The version to parse. 

57 

58 Returns: 

59 semver_version: The semantic version. 

60 prefix: The version prefix. 

61 """ 

62 warnings.warn( 

63 "This function is deprecated in favor of `git_changelog.versioning.parse_semver`", 

64 DeprecationWarning, 

65 stacklevel=2, 

66 ) 

67 return parse_semver(version) 

68 

69 

70class Section: 

71 """A list of commits grouped by section_type.""" 

72 

73 def __init__(self, section_type: str = "", commits: list[Commit] | None = None): 

74 """Initialization method. 

75 

76 Arguments: 

77 section_type: The section section_type. 

78 commits: The list of commits. 

79 """ 

80 self.type: str = section_type 

81 self.commits: list[Commit] = commits or [] 

82 

83 

84class Version: 

85 """A class to represent a changelog version.""" 

86 

87 def __init__( 

88 self, 

89 tag: str = "", 

90 date: datetime.date | None = None, 

91 sections: list[Section] | None = None, 

92 commits: list[Commit] | None = None, 

93 url: str = "", 

94 compare_url: str = "", 

95 ): 

96 """Initialization method. 

97 

98 Arguments: 

99 tag: The version tag. 

100 date: The version date. 

101 sections: The version sections. 

102 commits: The version commits. 

103 url: The version URL. 

104 compare_url: The version 'compare' URL. 

105 """ 

106 self.tag = tag 

107 self.date = date 

108 

109 self.sections_list: list[Section] = sections or [] 

110 self.sections_dict: dict[str, Section] = {section.type: section for section in self.sections_list} 

111 self.commits: list[Commit] = commits or [] 

112 self.url: str = url 

113 self.compare_url: str = compare_url 

114 self.previous_version: Version | None = None 

115 self.next_version: Version | None = None 

116 self.planned_tag: str | None = None 

117 

118 @property 

119 def typed_sections(self) -> list[Section]: 

120 """Return typed sections only. 

121 

122 Returns: 

123 The typed sections. 

124 """ 

125 return [section for section in self.sections_list if section.type] 

126 

127 @property 

128 def untyped_section(self) -> Section | None: 

129 """Return untyped section if any. 

130 

131 Returns: 

132 The untyped section if any. 

133 """ 

134 return self.sections_dict.get("", None) 

135 

136 @property 

137 def is_major(self) -> bool: 

138 """Tell if this version is a major one. 

139 

140 Returns: 

141 Whether this version is major. 

142 """ 

143 return self.tag.split(".", 1)[1].startswith("0.0") 

144 

145 @property 

146 def is_minor(self) -> bool: 

147 """Tell if this version is a minor one. 

148 

149 Returns: 

150 Whether this version is minor. 

151 """ 

152 return bool(self.tag.split(".", 2)[2]) 

153 

154 def add_commit(self, commit: Commit) -> None: 

155 """Register the given commit and add it to the relevant section based on its message convention. 

156 

157 Arguments: 

158 commit: The git commit. 

159 """ 

160 self.commits.append(commit) 

161 commit.version = self.tag or "HEAD" 

162 if commit_type := commit.convention.get("type"): 

163 if commit_type not in self.sections_dict: 

164 section = Section(section_type=commit_type) 

165 self.sections_list.append(section) 

166 self.sections_dict[commit_type] = section 

167 self.sections_dict[commit_type].commits.append(commit) 

168 

169 

170class Changelog: 

171 """The main changelog class.""" 

172 

173 MARKER: ClassVar[str] = "--GIT-CHANGELOG MARKER--" 

174 FORMAT: ClassVar[str] = ( 

175 r"%H%n" # commit commit_hash 

176 r"%an%n" # author name 

177 r"%ae%n" # author email 

178 r"%ad%n" # author date 

179 r"%cn%n" # committer name 

180 r"%ce%n" # committer email 

181 r"%cd%n" # committer date 

182 r"%D%n" # tag 

183 r"%P%n" # parent hashes 

184 r"%s%n" # subject 

185 r"%b%n" + MARKER # body 

186 ) 

187 CONVENTION: ClassVar[dict[str, type[CommitConvention]]] = { 

188 "basic": BasicConvention, 

189 "angular": AngularConvention, 

190 "conventional": ConventionalCommitConvention, 

191 } 

192 

193 def __init__( 

194 self, 

195 repository: str | Path, 

196 *, 

197 provider: ProviderRefParser | type[ProviderRefParser] | None = None, 

198 convention: ConventionType | None = None, 

199 parse_provider_refs: bool = False, 

200 parse_trailers: bool = False, 

201 sections: list[str] | None = None, 

202 bump_latest: bool = False, 

203 bump: str | None = None, 

204 zerover: bool = True, 

205 filter_commits: str | None = None, 

206 versioning: Literal["semver", "pep440"] = "semver", 

207 ): 

208 """Initialization method. 

209 

210 Arguments: 

211 repository: The repository (directory) for which to build the changelog. 

212 provider: The provider to use (github.com, gitlab.com, etc.). 

213 convention: The commit convention to use (angular, etc.). 

214 parse_provider_refs: Whether to parse provider-specific references in the commit messages. 

215 parse_trailers: Whether to parse Git trailers in the commit messages. 

216 sections: The sections to render (features, bug fixes, etc.). 

217 bump_latest: Deprecated, use `bump="auto"` instead. Whether to try and bump latest version to guess new one. 

218 bump: Whether to try and bump to a given version. 

219 zerover: Keep major version at zero, even for breaking changes. 

220 filter_commits: The Git revision-range used to filter commits in git-log (e.g: `v1.0.1..`). 

221 """ 

222 self.repository: str | Path = repository 

223 self.parse_provider_refs: bool = parse_provider_refs 

224 self.parse_trailers: bool = parse_trailers 

225 self.zerover: bool = zerover 

226 self.filter_commits: str | None = filter_commits 

227 

228 # set provider 

229 if not isinstance(provider, ProviderRefParser): 229 ↛ 245line 229 didn't jump to line 245, because the condition on line 229 was never false

230 remote_url = self.get_remote_url() 

231 split = remote_url.split("/") 

232 provider_url = "/".join(split[:3]) 

233 namespace, project = "/".join(split[3:-1]), split[-1] 

234 if callable(provider): 234 ↛ 235line 234 didn't jump to line 235, because the condition on line 234 was never true

235 provider = provider(namespace, project, url=provider_url) 

236 elif "github" in provider_url: 

237 provider = GitHub(namespace, project, url=provider_url) 

238 elif "gitlab" in provider_url: 238 ↛ 239line 238 didn't jump to line 239, because the condition on line 238 was never true

239 provider = GitLab(namespace, project, url=provider_url) 

240 elif "bitbucket" in provider_url: 240 ↛ 241line 240 didn't jump to line 241, because the condition on line 240 was never true

241 provider = Bitbucket(namespace, project, url=provider_url) 

242 else: 

243 provider = None 

244 self.remote_url: str = remote_url 

245 self.provider = provider 

246 

247 # set convention 

248 if isinstance(convention, str): 

249 try: 

250 convention = self.CONVENTION[convention]() 

251 except KeyError: 

252 print( # noqa: T201 

253 f"git-changelog: no such convention available: {convention}, using default convention", 

254 file=sys.stderr, 

255 ) 

256 convention = BasicConvention() 

257 elif convention is None: 

258 convention = BasicConvention() 

259 elif not isinstance(convention, CommitConvention) and issubclass(convention, CommitConvention): 259 ↛ 261line 259 didn't jump to line 261, because the condition on line 259 was never false

260 convention = convention() 

261 self.convention: CommitConvention = convention 

262 

263 # set sections 

264 sections = ( 

265 [self.convention.TYPES[section] for section in sections] if sections else self.convention.DEFAULT_RENDER 

266 ) 

267 self.sections = sections 

268 

269 # get version parser based on selected versioning scheme 

270 self.version_parser, self.version_bumper = { 

271 "semver": (parse_semver, bump_semver), 

272 "pep440": (parse_pep440, bump_pep440), 

273 }[versioning] 

274 

275 # get git log and parse it into list of commits 

276 self.raw_log: str = self.get_log() 

277 self.commits: list[Commit] = self.parse_commits() 

278 self.tag_commits: list[Commit] = [commit for commit in self.commits[1:] if commit.tag] 

279 self.tag_commits.insert(0, self.commits[0]) 

280 

281 # apply dates to commits and group them by version 

282 v_list, v_dict = self._group_commits_by_version() 

283 self.versions_list = v_list 

284 self.versions_dict = v_dict 

285 

286 # TODO: remove at some point 

287 if bump_latest: 287 ↛ 288line 287 didn't jump to line 288, because the condition on line 287 was never true

288 warnings.warn( 

289 "`bump_latest=True` is deprecated in favor of `bump='auto'`", 

290 DeprecationWarning, 

291 stacklevel=1, 

292 ) 

293 if bump is None: 

294 bump = "auto" 

295 if bump: 

296 self._bump(bump) 

297 

298 def run_git(self, *args: str) -> str: 

299 """Run a git command in the chosen repository. 

300 

301 Arguments: 

302 *args: Arguments passed to the git command. 

303 

304 Returns: 

305 The git command output. 

306 """ 

307 return check_output(["git", *args], cwd=self.repository).decode("utf8") # noqa: S603,S607 

308 

309 def get_remote_url(self) -> str: 

310 """Get the git remote URL for the repository. 

311 

312 Returns: 

313 The origin remote URL. 

314 """ 

315 remote = "remote." + os.environ.get("GIT_CHANGELOG_REMOTE", "origin") + ".url" 

316 git_url = self.run_git("config", "--default", "", "--get", remote).rstrip("\n") 

317 if git_url.startswith("git@"): 

318 git_url = git_url.replace(":", "/", 1).replace("git@", "https://", 1) 

319 if git_url.endswith(".git"): 319 ↛ 320line 319 didn't jump to line 320, because the condition on line 319 was never true

320 git_url = git_url[:-4] 

321 

322 # Remove credentials from the URL. 

323 if git_url.startswith(("http://", "https://")): 

324 # (addressing scheme, network location, path, query, fragment identifier) 

325 urlparts = list(urlsplit(git_url)) 

326 urlparts[1] = urlparts[1].split("@", 1)[-1] 

327 git_url = urlunsplit(urlparts) 

328 

329 return git_url 

330 

331 def get_log(self) -> str: 

332 """Get the `git log` output. 

333 

334 Returns: 

335 The output of the `git log` command, with a particular format. 

336 """ 

337 if self.filter_commits: 

338 try: 

339 return self.run_git("log", "--date=unix", "--format=" + self.FORMAT, self.filter_commits) 

340 except CalledProcessError as e: 

341 raise ValueError( 

342 f"An error ocurred. Maybe the provided git-log revision-range is not valid: '{self.filter_commits}'", 

343 ) from e 

344 

345 # No revision-range provided. Call normally 

346 return self.run_git("log", "--date=unix", "--format=" + self.FORMAT) 

347 

348 def parse_commits(self) -> list[Commit]: 

349 """Parse the output of 'git log' into a list of commits. 

350 

351 The commits build a Git commit graph by referencing their parent commits. 

352 Commits are ordered from newest to oldest. 

353 

354 Returns: 

355 The list of commits. 

356 """ 

357 lines = self.raw_log.split("\n") 

358 size = len(lines) - 1 # Don't count last blank line. 

359 pos = 0 

360 

361 commits_map: dict[str, Commit] = {} 

362 

363 while pos < size: 

364 # Build message body. 

365 nbl_index = 10 

366 body = [] 

367 while lines[pos + nbl_index] != self.MARKER: 

368 body.append(lines[pos + nbl_index].strip("\r")) 

369 nbl_index += 1 

370 

371 # Build commit object. 

372 commit = Commit( 

373 commit_hash=lines[pos], 

374 author_name=lines[pos + 1], 

375 author_email=lines[pos + 2], 

376 author_date=lines[pos + 3], 

377 committer_name=lines[pos + 4], 

378 committer_email=lines[pos + 5], 

379 committer_date=lines[pos + 6], 

380 refs=lines[pos + 7], 

381 parent_hashes=lines[pos + 8], 

382 commits_map=commits_map, 

383 subject=lines[pos + 9], 

384 body=body, 

385 parse_trailers=self.parse_trailers, 

386 version_parser=self.version_parser, 

387 ) 

388 

389 pos += nbl_index + 1 

390 

391 # Expand commit object with provider parsing. 

392 if self.provider: 

393 commit.update_with_provider(self.provider, parse_refs=self.parse_provider_refs) 

394 

395 # Set the commit url based on remote_url (could be wrong). 

396 elif self.remote_url: 396 ↛ 397line 396 didn't jump to line 397, because the condition on line 396 was never true

397 commit.url = self.remote_url + "/commit/" + commit.hash 

398 

399 # Expand commit object with convention parsing. 

400 if self.convention: 400 ↛ 403line 400 didn't jump to line 403, because the condition on line 400 was never false

401 commit.update_with_convention(self.convention) 

402 

403 commits_map[commit.hash] = commit 

404 

405 return list(commits_map.values()) 

406 

407 def _group_commits_by_version(self) -> tuple[list[Version], dict[str, Version]]: 

408 """Group commits into versions. 

409 

410 Commits are assigned to the version they were first released with. 

411 A commit is assigned to exactly one version. 

412 

413 Returns: 

414 versions_list: The list of versions order descending by timestamp. 

415 versions_dict: A dictionary of versions with the tag name as keys. 

416 """ 

417 versions_dict: dict[str, Version] = {} 

418 versions_list: list[Version] = [] 

419 previous_versions: dict[str, str] = {} 

420 

421 # Iterate in reversed order (oldest to newest tag) to assign commits to the first version they were released with. 

422 for tag_commit in reversed(self.tag_commits): 

423 # Create new version object. 

424 version = self._create_version(tag_commit) 

425 versions_dict[tag_commit.version] = version 

426 versions_list.insert(0, version) 

427 

428 # Find all commits for this version by following the commit graph. 

429 version.add_commit(tag_commit) 

430 previous_parsed_version: ParsedVersion | None = None 

431 next_commits = tag_commit.parent_commits # Always new: we can mutate it. 

432 while next_commits: 

433 next_commit = next_commits.pop(0) 

434 if next_commit.tag: 

435 parsed_version, _ = self.version_parser(next_commit.tag) 

436 if not previous_parsed_version or parsed_version > previous_parsed_version: 

437 previous_parsed_version = parsed_version 

438 previous_versions[version.tag] = next_commit.tag 

439 elif not next_commit.version: 

440 version.add_commit(next_commit) 

441 next_commits.extend(next_commit.parent_commits) 

442 

443 self._assign_previous_versions(versions_dict, previous_versions) 

444 return versions_list, versions_dict 

445 

446 def _create_version(self, commit: Commit) -> Version: 

447 date = commit.committer_date.date() if commit.version else datetime.date.today() # noqa: DTZ011 

448 version = Version(tag=commit.version, date=date) 

449 if self.provider: 

450 version.url = self.provider.get_tag_url(tag=commit.version) 

451 return version 

452 

453 def _assign_previous_versions(self, versions_dict: dict[str, Version], previous_versions: dict[str, str]) -> None: 

454 """Assign each version its previous version and create the compare URL. 

455 

456 The previous version is defined as the version with the highest semantic version, 

457 that is found by following the commit graph. 

458 

459 If no previous version is found, either because it is the first commit or 

460 due to the commit filter excluding it, the compare URL is created with the 

461 first commit (oldest). 

462 

463 Arguments: 

464 versions_dict: A dictionary of versions with the tag name as keys. 

465 previous_versions: A dictonary with version and previous version. 

466 """ 

467 for version in versions_dict.values(): 

468 previous_version = previous_versions.get(version.tag, version.commits[-1].hash) 

469 version.previous_version = versions_dict.get(previous_version) 

470 if version.previous_version: 

471 version.previous_version.next_version = version 

472 if self.provider: 

473 version.compare_url = self.provider.get_compare_url( 

474 base=previous_version, 

475 target=version.tag or "HEAD", 

476 ) 

477 

478 def _bump(self, version: str) -> None: 

479 last_version = self.versions_list[0] 

480 if not last_version.tag: 

481 if last_version.previous_version: 

482 last_tag = last_version.previous_version.tag 

483 else: 

484 last_tag = self.version_bumper.initial 

485 version, *plus = version.split("+") 

486 if version == "auto": 

487 # guess the next version number based on last version and recent commits 

488 version = "patch" 

489 for commit in last_version.commits: 

490 if commit.convention["is_major"]: 490 ↛ 491line 490 didn't jump to line 491, because the condition on line 490 was never true

491 version = "major" 

492 break 

493 if commit.convention["is_minor"]: 

494 version = "minor" 

495 version = "+".join((version, *plus)) 

496 if version in self.version_bumper.strategies: 

497 # bump version 

498 last_version.planned_tag = self.version_bumper(last_tag, version, zerover=self.zerover) 

499 else: 

500 # user specified version 

501 try: 

502 self.version_bumper(version) 

503 except ValueError as error: 

504 raise ValueError(f"{error}; typo in bumping strategy? Check the CLI help and our docs") from error 

505 last_version.planned_tag = version 

506 # update URLs 

507 if self.provider: 507 ↛ exitline 507 didn't return from function '_bump', because the condition on line 507 was never false

508 last_version.url = self.provider.get_tag_url(tag=last_version.planned_tag) 

509 last_version.compare_url = self.provider.get_compare_url( 

510 base=last_version.previous_version.tag 

511 if last_version.previous_version 

512 else last_version.commits[-1].hash, 

513 target=last_version.planned_tag, 

514 )