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
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-04 17:35 +0200
1"""The module responsible for building the data."""
3from __future__ import annotations
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
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
23if TYPE_CHECKING:
24 from pathlib import Path
26 from git_changelog.versioning import SemVerVersion
28ConventionType = Union[str, CommitConvention, Type[CommitConvention]]
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.
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.
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)
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.
55 Arguments:
56 version: The version to parse.
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)
70class Section:
71 """A list of commits grouped by section_type."""
73 def __init__(self, section_type: str = "", commits: list[Commit] | None = None):
74 """Initialization method.
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 []
84class Version:
85 """A class to represent a changelog version."""
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.
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
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
118 @property
119 def typed_sections(self) -> list[Section]:
120 """Return typed sections only.
122 Returns:
123 The typed sections.
124 """
125 return [section for section in self.sections_list if section.type]
127 @property
128 def untyped_section(self) -> Section | None:
129 """Return untyped section if any.
131 Returns:
132 The untyped section if any.
133 """
134 return self.sections_dict.get("", None)
136 @property
137 def is_major(self) -> bool:
138 """Tell if this version is a major one.
140 Returns:
141 Whether this version is major.
142 """
143 return self.tag.split(".", 1)[1].startswith("0.0")
145 @property
146 def is_minor(self) -> bool:
147 """Tell if this version is a minor one.
149 Returns:
150 Whether this version is minor.
151 """
152 return bool(self.tag.split(".", 2)[2])
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.
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)
170class Changelog:
171 """The main changelog class."""
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 }
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.
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
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
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
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
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]
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])
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
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)
298 def run_git(self, *args: str) -> str:
299 """Run a git command in the chosen repository.
301 Arguments:
302 *args: Arguments passed to the git command.
304 Returns:
305 The git command output.
306 """
307 return check_output(["git", *args], cwd=self.repository).decode("utf8") # noqa: S603,S607
309 def get_remote_url(self) -> str:
310 """Get the git remote URL for the repository.
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]
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)
329 return git_url
331 def get_log(self) -> str:
332 """Get the `git log` output.
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
345 # No revision-range provided. Call normally
346 return self.run_git("log", "--date=unix", "--format=" + self.FORMAT)
348 def parse_commits(self) -> list[Commit]:
349 """Parse the output of 'git log' into a list of commits.
351 The commits build a Git commit graph by referencing their parent commits.
352 Commits are ordered from newest to oldest.
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
361 commits_map: dict[str, Commit] = {}
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
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 )
389 pos += nbl_index + 1
391 # Expand commit object with provider parsing.
392 if self.provider:
393 commit.update_with_provider(self.provider, parse_refs=self.parse_provider_refs)
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
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)
403 commits_map[commit.hash] = commit
405 return list(commits_map.values())
407 def _group_commits_by_version(self) -> tuple[list[Version], dict[str, Version]]:
408 """Group commits into versions.
410 Commits are assigned to the version they were first released with.
411 A commit is assigned to exactly one version.
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] = {}
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)
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)
443 self._assign_previous_versions(versions_dict, previous_versions)
444 return versions_list, versions_dict
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
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.
456 The previous version is defined as the version with the highest semantic version,
457 that is found by following the commit graph.
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).
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 )
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 )