noirbizarre/emotional

View on GitHub
emotional/plugin.py

Summary

Maintainability
A
50 mins
Test Coverage
A
99%
from __future__ import annotations

import itertools

from collections import OrderedDict
from typing import Any, Iterable

from commitizen.cz.base import BaseCommitizen, BaseConfig
from commitizen.cz.utils import multiple_line_breaker, required_validator
from commitizen.git import GitCommit
from jinja2 import PackageLoader

from . import github, gitlab, jira
from .config import CommitType, EmotionalConfig, Increment
from .utils import render_template

INTEGRATIONS = github, gitlab, jira

RE_EMOJI = (
    "["
    "\U0001f1e0-\U0001f1ff"  # flags (iOS)
    "\U0001f300-\U0001f5ff"  # symbols & pictographs
    "\U0001f600-\U0001f64f"  # emoticons
    "\U0001f680-\U0001f6ff"  # transport & map symbols
    "\U0001f700-\U0001f77f"  # alchemical symbols
    "\U0001f780-\U0001f7ff"  # Geometric Shapes Extended
    "\U0001f800-\U0001f8ff"  # Supplemental Arrows-C
    "\U0001f900-\U0001f9ff"  # Supplemental Symbols and Pictographs
    "\U0001fa00-\U0001fa6f"  # Chess Symbols
    "\U0001fa70-\U0001faff"  # Symbols and Pictographs Extended-A
    "\U00002702-\U000027b0"  # Dingbats
    "]"
)

BREAKING_CHANGE_TYPE = "BREAKING CHANGE"


def parse_scope(text):
    if not text:
        return ""

    scope = text.strip().split()
    if len(scope) == 1:
        return scope[0]

    return "-".join(scope)


def parse_subject(text):
    if isinstance(text, str):
        text = text.strip(".").strip()

    return required_validator(text, msg="Subject is required.")


class Emotional(BaseCommitizen):
    template_loader = PackageLoader("emotional", "templates")

    def __init__(self, config: BaseConfig):
        super().__init__(config)
        self.emotional_config = EmotionalConfig(config.settings)

    @property
    def known_types(self) -> dict[str, CommitType]:
        return dict(
            itertools.chain(
                ((t.type, t) for t in self.emotional_config.known_types),
                (
                    (alias, t)
                    for t in self.emotional_config.known_types
                    for alias in t.aliases
                    if t.aliases
                ),
            )
        )

    @property
    def re_known_types(self) -> str:
        return "|".join(t.regex or k for k, t in self.known_types.items())

    @property
    def bump_pattern(self) -> str:
        """Regex to extract information from commit (subject and body)"""
        re_types = "|".join(t.regex or k for k, t in self.known_types.items() if t.bump)
        return rf"^((({re_types})(\(.+\))?(!)?)|\w+!):"

    @property
    def bump_map(self) -> dict[str, Increment]:
        """
        Mapping the extracted information to a SemVer increment type (MAJOR, MINOR, PATCH)
        """
        return OrderedDict(
            (
                (r"^.+!$", "MAJOR"),
                *((rf"^{t.regex or k}", t.bump) for k, t in self.known_types.items() if t.bump),
            )
        )

    @property
    def bump_map_major_version_zero(self) -> dict[str, Increment]:
        return OrderedDict(
            (pattern, increment.replace("MAJOR", "MINOR"))
            for pattern, increment in self.bump_map.items()
        )

    def questions(self) -> list:
        questions: list[dict[str, Any]] = [
            {
                "type": "list",
                "name": "prefix",
                "message": "Select the type of change you are committing",
                "choices": [
                    {
                        "value": t.type,
                        "name": f"{t.emoji} {t.type}: {t.description}",
                        "key": t.shortcut,
                    }
                    for t in self.emotional_config.known_types
                    if t.question
                ],
            },
            {
                "type": "input",
                "name": "scope",
                "message": (
                    "Scope. Define Could be anything specifying place of the "
                    "commit change (users, db, poll):\n"
                ),
                "filter": parse_scope,
            },
            {
                "type": "input",
                "name": "subject",
                "filter": parse_subject,
                "message": (
                    "Subject. Concise description of the changes. "
                    "Imperative, lower case and no period:\n"
                ),
            },
            {
                "type": "input",
                "name": "body",
                "message": (
                    "Body. Motivation for the change and contrast this with previous behavior:\n"
                ),
                "filter": multiple_line_breaker,
            },
            {
                "type": "confirm",
                "message": "Is this a BREAKING CHANGE?",
                "name": "is_breaking_change",
                "default": False,
            },
            {
                "when": lambda x: x["is_breaking_change"],
                "type": "input",
                "name": "breaking_change",
                "message": "Breaking changes. Details the breakage:\n",
                "filter": multiple_line_breaker,
            },
            {
                "type": "input",
                "name": "footer",
                "message": (
                    "Footer. Information about Breaking Changes and "
                    "reference issues that this commit impacts:\n"
                ),
                "filter": multiple_line_breaker,
            },
        ]
        for integration in INTEGRATIONS:
            if hasattr(integration, "questions"):
                questions = integration.questions(self.emotional_config, questions)
        return questions

    def message(self, answers: dict) -> str:
        prefix = answers["prefix"]
        scope = answers["scope"]
        subject = answers["subject"]
        body = answers["body"]
        is_breaking_change = answers["is_breaking_change"]
        breaking_change = answers.get("breaking_change")
        footer = answers.get("footer", "")
        extra = ""

        if scope:
            scope = f"({scope})"

        if is_breaking_change and not breaking_change:
            scope += "!"

        if body:
            body = f"\n\n{body}"

        if is_breaking_change and breaking_change:
            footer = f"BREAKING CHANGE: {breaking_change}\n{footer}"

        if footer:
            footer = f"\n\n{footer}"

        message = f"{prefix}{scope}: {subject}{extra}{body}{footer}"

        return message

    def changelog_message_builder_hook(
        self, parsed_message: dict, commit: GitCommit
    ) -> dict[str, Any] | Iterable[dict[str, Any]] | None:
        """add github and jira links to the readme"""
        # Remap breaking changes type
        if parsed_message.get("breaking"):
            parsed_message["change_type"] = BREAKING_CHANGE_TYPE
        # Filter out changes not meant to appear in the changelog
        if (ct := self.known_types.get(parsed_message["change_type"])) and not ct.changelog:
            return None
        for integration in INTEGRATIONS:
            if hasattr(integration, "changelog_message_hook"):
                parsed_message = integration.changelog_message_hook(
                    self.emotional_config, parsed_message, commit
                )
        return parsed_message

    def changelog_hook(self, full: str, partial: str | None) -> str:
        """
        Process resulting changelog to keep 1 empty line at the end of file
        """
        changelog = partial or full
        return changelog.rstrip()

    @property
    def change_type_order(self) -> list[CommitType]:
        return [
            type for type in self.emotional_config.known_types if type.changelog and type.heading
        ]

    @property
    def changelog_pattern(self) -> str:
        re_known_types = "|".join(k for k, t in self.known_types.items())
        return rf"\A({re_known_types})(\(.+\))?(!)?"

    @property
    def commit_parser(self) -> str:
        return (
            rf"\A(?:(?P<emoji>{RE_EMOJI})\s*)?"
            rf"(?P<change_type>{self.re_known_types})"
            r"(?:\((?P<scope>[^()\r\n]*)\)|\()?"
            r"(?P<breaking>!)?:\s"
            r"(?P<message>.*)?"
            r"(?:\n{2,}(?P<body>.*))?"
            r"(?:\n{2,}(?P<footer>.*))?"
        )

    @property
    def change_type_map(self) -> dict[str, CommitType]:
        return self.known_types

    def info(self) -> str:
        return render_template("info.md.jinja", config=self.emotional_config)

    def example(self) -> str:
        return render_template("example.jinja", config=self.emotional_config)

    def schema(self) -> str:
        return (
            "<type>(<scope>): <subject>\n"
            "<BLANK LINE>\n"
            "<body>\n"
            "<BLANK LINE>\n"
            "(BREAKING CHANGE: <breaking changes>)*\n"
            "(<footers>)*"
        )

    def schema_pattern(self) -> str:
        PATTERN = (
            r"(?s)"  # To explicitly make . match new line
            rf"({self.re_known_types})"  # type
            r"(\(\S+\))?!?:"  # scope
            r"( [^\n\r]+)"  # subject
            r"((\n\n.*)|(\s*))?$"
        )
        return PATTERN

    @property
    def template_extras(self) -> dict[str, Any]:
        return {"config": self.emotional_config, "settings": self.emotional_config.settings}