Source code for semantic_release.changelog.release_history

from __future__ import annotations

import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, TypedDict

from git.objects.tag import TagObject

from semantic_release.commit_parser import ParseError
from semantic_release.commit_parser.token import ParsedCommit
from semantic_release.commit_parser.util import force_str
from semantic_release.enums import LevelBump
from semantic_release.helpers import validate_types_in_sequence
from semantic_release.version.algorithm import tags_and_versions

if TYPE_CHECKING:  # pragma: no cover
    from re import Pattern
    from typing import Iterable, Iterator

    from git.repo.base import Repo
    from git.util import Actor

    from semantic_release.commit_parser import (
        CommitParser,
        ParseResult,
        ParserOptions,
    )
    from semantic_release.version.translator import VersionTranslator
    from semantic_release.version.version import Version

log = logging.getLogger(__name__)


[docs] class ReleaseHistory:
[docs] @classmethod def from_git_history( cls, repo: Repo, translator: VersionTranslator, commit_parser: CommitParser[ParseResult, ParserOptions], exclude_commit_patterns: Iterable[Pattern[str]] = (), ) -> ReleaseHistory: all_git_tags_and_versions = tags_and_versions(repo.tags, translator) unreleased: dict[str, list[ParseResult]] = defaultdict(list) released: dict[Version, Release] = {} # Performance optimization: create a mapping of tag sha to version # so we can quickly look up the version for a given commit based on sha tag_sha_2_version_lookup = { tag.commit.hexsha: (tag, version) for tag, version in all_git_tags_and_versions } ignore_merge_commits = bool( hasattr(commit_parser, "options") and hasattr(commit_parser.options, "ignore_merge_commits") and getattr(commit_parser.options, "ignore_merge_commits") # noqa: B009 ) # Strategy: # Loop through commits in history, parsing as we go. # Add these commits to `unreleased` as a key-value mapping # of type_ to ParseResult, until we encounter a tag # which matches a commit. # Then, we add the version for that tag as a key to `released`, # and set the value to an empty dict. Into that empty dict # we place the key-value mapping type_ to ParseResult as before. # We do this until we encounter a commit which another tag matches. the_version: Version | None = None for commit in repo.iter_commits("HEAD", topo_order=True): # Determine if we have found another release log.debug("checking if commit %s matches any tags", commit.hexsha[:7]) t_v = tag_sha_2_version_lookup.get(commit.hexsha, None) if t_v is None: log.debug("no tags correspond to commit %s", commit.hexsha) else: # Unpack the tuple (overriding the current version) tag, the_version = t_v # we have found the latest commit introduced by this tag # so we create a new Release entry log.debug("found commit %s for tag %s", commit.hexsha, tag.name) # tag.object is a Commit if the tag is lightweight, otherwise # it is a TagObject with additional metadata about the tag if isinstance(tag.object, TagObject): tagger = tag.object.tagger committer = tag.object.tagger.committer() _tz = timezone(timedelta(seconds=-1 * tag.object.tagger_tz_offset)) tagged_date = datetime.fromtimestamp(tag.object.tagged_date, tz=_tz) else: # For some reason, sometimes tag.object is a Commit tagger = tag.object.author committer = tag.object.author _tz = timezone(timedelta(seconds=-1 * tag.object.author_tz_offset)) tagged_date = datetime.fromtimestamp( tag.object.committed_date, tz=_tz ) release = Release( tagger=tagger, committer=committer, tagged_date=tagged_date, elements=defaultdict(list), version=the_version, ) released.setdefault(the_version, release) log.info( "parsing commit [%s] %s", commit.hexsha[:8], str(commit.message).replace("\n", " ")[:54], ) # returns a ParseResult or list of ParseResult objects, # it is usually one, but we split a commit if a squashed merge is detected parse_results = commit_parser.parse(commit) if not any( ( isinstance(parse_results, (ParseError, ParsedCommit)), ( ( isinstance(parse_results, list) or type(parse_results) == tuple ) and validate_types_in_sequence( parse_results, (ParseError, ParsedCommit) ) ), ) ): raise TypeError("Unexpected type returned from commit_parser.parse") results: list[ParseResult] = [ *( [parse_results] if isinstance(parse_results, (ParseError, ParsedCommit)) else parse_results ), ] is_squash_commit = bool(len(results) > 1) # iterate through parsed commits to add to changelog definition for parsed_result in results: commit_message = force_str(parsed_result.commit.message) commit_type = ( "unknown" if isinstance(parsed_result, ParseError) else parsed_result.type ) log.debug("commit has type '%s'", commit_type) has_exclusion_match = any( pattern.match(commit_message) for pattern in exclude_commit_patterns ) commit_level_bump = ( LevelBump.NO_RELEASE if isinstance(parsed_result, ParseError) else parsed_result.bump ) if ignore_merge_commits and parsed_result.is_merge_commit(): log.info("Excluding merge commit[%s]", parsed_result.short_hash) continue # Skip excluded commits except for any commit causing a version bump # Reasoning: if a commit causes a version bump, and no other commits # are included, then the changelog will be empty. Even if ther was other # commits included, the true reason for a version bump would be missing. if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE: log.info( "Excluding %s commit[%s] %s", "piece of squashed" if is_squash_commit else "", parsed_result.short_hash, commit_message.split("\n", maxsplit=1)[0][:20], ) continue if ( isinstance(parsed_result, ParsedCommit) and not parsed_result.include_in_changelog ): log.info( str.join( " ", [ "Excluding commit[%s] because parser determined", "it should not included in the changelog", ], ), parsed_result.short_hash, ) continue if the_version is None: log.info( "[Unreleased] adding commit[%s] to unreleased '%s'", parsed_result.short_hash, commit_type, ) unreleased[commit_type].append(parsed_result) continue log.info( "[%s] adding commit[%s] to release '%s'", the_version, parsed_result.short_hash, commit_type, ) released[the_version]["elements"][commit_type].append(parsed_result) return cls(unreleased=unreleased, released=released)
def __init__( self, unreleased: dict[str, list[ParseResult]], released: dict[Version, Release] ) -> None: self.released = released self.unreleased = unreleased def __iter__( self, ) -> Iterator[dict[str, list[ParseResult]] | dict[Version, Release]]: """ Enables unpacking: >>> rh = ReleaseHistory(...) >>> unreleased, released = rh """ yield self.unreleased yield self.released
[docs] def release( self, version: Version, tagger: Actor, committer: Actor, tagged_date: datetime ) -> ReleaseHistory: if version in self.released: raise ValueError(f"{version} has already been released!") # return a new instance to avoid potential accidental # mutation return ReleaseHistory( unreleased={}, released={ version: { "tagger": tagger, "committer": committer, "tagged_date": tagged_date, "elements": self.unreleased, "version": version, }, **self.released, }, )
def __repr__(self) -> str: return ( f"<{type(self).__qualname__}: " f"{sum(len(commits) for commits in self.unreleased.values())} " f"commits unreleased, {len(self.released)} versions released>" )
[docs] class Release(TypedDict): tagger: Actor committer: Actor tagged_date: datetime elements: dict[str, list[ParseResult]] version: Version