"""Methods for generating a release hint."""
import fnmatch
import re
from typing import List, Optional, Union
from generate_changelog.configuration import RELEASE_TYPE_ORDER, Configuration
from generate_changelog.context import CommitContext, VersionContext
[docs]
class InvalidRuleError(Exception):
"""The evaluated rule is invalid."""
pass
[docs]
class ReleaseRule:
"""
A commit evaluation rule for hinting at the level of change.
Args:
match_result: Release type if a commit context matches the rule.
no_match_result: Release type if a commit context doesn't match the rule.
grouping: The partial or exact grouping of the commit context
path: A globbing pattern that matches against files included in the commit
branch: A regular expression pattern to match against the branch
"""
[docs]
def __init__(
self,
match_result: Optional[str],
no_match_result: Optional[str] = "no-release",
grouping: Union[str, tuple, list, None] = None,
path: Optional[str] = None,
branch: Optional[str] = None,
):
self.match_result = match_result
self.no_match_result = no_match_result
self.grouping = grouping if grouping != "*" else None
self.path = path if path != "*" else None
self.branch = branch or None
self.is_valid = any([self.path, self.grouping, self.branch])
[docs]
def matches_grouping(self, commit: CommitContext) -> bool:
"""
Does the commit grouping match the rule?
- If ``self.grouping`` is a string, it checks if the string is in the commit's ``grouping``.
- If ``self.grouping`` is a list or tuple of strings, it must match the commit's ``grouping``.
- If ``self.grouping`` is a list or tuple of strings and the last item in the list is a "*",
it must match the beginning of the commit's ``grouping``.
- If ``self.grouping`` is None, it will return a match
Args:
commit: The commit context whose grouping should match
Returns:
``True`` if the grouping matches
"""
if self.grouping is None:
return True
elif isinstance(self.grouping, str):
return self.grouping in commit.grouping
elif not isinstance(self.grouping, (list, tuple)):
return False
if "*" not in self.grouping:
return tuple(self.grouping) == commit.grouping
split_index = self.grouping.index("*")
prefix = self.grouping[:split_index]
return prefix == commit.grouping[: len(prefix)]
[docs]
def matches_path(self, commit: CommitContext) -> bool:
"""
Do any of the paths in the commit match the rule?
Args:
commit: The commit context whose files should match
Returns:
``True`` if any file in the commit context matches the pattern or if ``self.path`` is ``None``
"""
if not self.path:
return True
re_pattern = fnmatch.translate(self.path)
return any(re.match(re_pattern, p) for p in commit.files)
[docs]
def matches_branch(self, current_branch: str) -> bool:
"""
Does the current branch match the rule?
Args:
current_branch: The name of the current branch
Returns:
``True`` if the current branch matches or if ``self.branch`` is ``None``
"""
return bool(re.match(self.branch, current_branch)) if self.branch else True
[docs]
def __call__(self, commit: CommitContext, current_branch: str) -> Optional[str]:
"""Evaluate the commit using this rule."""
if not self.is_valid:
raise InvalidRuleError()
matches_grouping = self.matches_grouping(commit)
matches_path = self.matches_path(commit)
matches_branch = self.matches_branch(current_branch)
return self.match_result if all([matches_grouping, matches_path, matches_branch]) else self.no_match_result
[docs]
class RuleProcessor:
"""
Process a commit through all the rules and return the suggestion.
Args:
rule_list: The list of dictionaries representing release rules
"""
[docs]
def __init__(self, rule_list: List[dict]):
self.rules = [ReleaseRule(**kwargs) for kwargs in rule_list]
[docs]
def __call__(self, commit: CommitContext, current_branch: str) -> Optional[str]:
"""
Return the result of applying all the rules to a commit.
Args:
commit: The commit context to apply rules to
current_branch: The name of the current branch
Returns:
The release hint
"""
suggestions = {rule(commit, current_branch) for rule in self.rules}
unknown_suggestions = suggestions - set(RELEASE_TYPE_ORDER)
if unknown_suggestions:
return unknown_suggestions.pop() # Return a random value from the unknowns
sorted_suggestions = sorted(suggestions, key=lambda s: RELEASE_TYPE_ORDER.index(s))
return sorted_suggestions[-1]
[docs]
def suggest_release_type(current_branch: str, version_contexts: List[VersionContext], config: Configuration) -> str:
"""
Suggest the type of release based on the unreleased commits.
Args:
current_branch: The name of the current branch
version_contexts: The processed commits to process
config: The current configuration
Returns:
The type of release based on the rules, or ``no-release``
"""
rule_processor = RuleProcessor(rule_list=config.release_hint_rules)
# If the latest release is not "unreleased", there is no need for a release
if version_contexts[0].label != config.unreleased_label:
return "no-release"
suggestions = set()
for commit_group in version_contexts[0].grouped_commits:
suggestions |= {rule_processor(commit, current_branch) for commit in commit_group.commits}
if not suggestions:
return "no-release"
sorted_suggestions = sorted(suggestions, key=lambda s: RELEASE_TYPE_ORDER.index(s))
return sorted_suggestions[-1] or "no-release"