Source code for semantic_release.commit_parser.scipy

"""
Parses commit messages using `scipy tags <scipy-style>`_ of the form::

    <tag>(<scope>): <subject>

    <body>


The elements <tag>, <scope> and <body> are optional. If no tag is present, the
commit will be added to the changelog section "None" and no version increment
will be performed.

While <scope> is supported here it isn't actually part of the scipy style.
If it is missing, parentheses around it are too. The commit should then be
of the form::

    <tag>: <subject>

    <body>

To communicate a breaking change add "BREAKING CHANGE" into the body at the
beginning of a paragraph. Fill this paragraph with information how to migrate
from the broken behavior to the new behavior. It will be added to the
"Breaking" section of the changelog.

Supported Tags::

    API, DEP, ENH, REV, BUG, MAINT, BENCH, BLD,
    DEV, DOC, STY, TST, REL, FEAT, TEST

Supported Changelog Sections::

    breaking, feature, fix, Other, None

.. _`scipy-style`: https://docs.scipy.org/doc/scipy/reference/dev/contributor/development_workflow.html#writing-the-commit-message
"""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Tuple

from pydantic.dataclasses import dataclass

from semantic_release.commit_parser._base import CommitParser, ParserOptions
from semantic_release.commit_parser.token import ParsedCommit, ParseError, ParseResult
from semantic_release.enums import LevelBump

if TYPE_CHECKING:
    from git.objects.commit import Commit

log = logging.getLogger(__name__)


def _logged_parse_error(commit: Commit, error: str) -> ParseError:
    log.debug(error)
    return ParseError(commit, error=error)


tag_to_section = {
    "API": "breaking",
    "BENCH": "None",
    "BLD": "fix",
    "BUG": "fix",
    "DEP": "breaking",
    "DEV": "None",
    "DOC": "documentation",
    "ENH": "feature",
    "MAINT": "fix",
    "REV": "Other",
    "STY": "None",
    "TST": "None",
    "REL": "None",
    # strictly speaking not part of the standard
    "FEAT": "feature",
    "TEST": "None",
}

_COMMIT_FILTER = "|".join(tag_to_section)


[docs]@dataclass class ScipyParserOptions(ParserOptions): major_tags: Tuple[str, ...] = ("API",) minor_tags: Tuple[str, ...] = ("DEP", "DEV", "ENH", "REV", "FEAT") patch_tags: Tuple[str, ...] = ("BLD", "BUG", "MAINT") allowed_tags: Tuple[str, ...] = ( *major_tags, *minor_tags, *patch_tags, "BENCH", "DOC", "STY", "TST", "REL", "TEST", ) default_level_bump: LevelBump = LevelBump.NO_RELEASE def __post_init__(self) -> None: self.tag_to_level = {tag: LevelBump.NO_RELEASE for tag in self.allowed_tags} for tag in self.patch_tags: self.tag_to_level[tag] = LevelBump.PATCH for tag in self.minor_tags: self.tag_to_level[tag] = LevelBump.MINOR for tag in self.major_tags: self.tag_to_level[tag] = LevelBump.MAJOR
[docs]class ScipyCommitParser(CommitParser[ParseResult, ScipyParserOptions]): """Parser for scipy-style commit messages""" # TODO: Deprecate in lieu of get_default_options() parser_options = ScipyParserOptions def __init__(self, options: ScipyParserOptions | None = None) -> None: super().__init__(options) self.re_parser = re.compile( rf"(?P<tag>{_COMMIT_FILTER})?" r"(?:\((?P<scope>[^\n]+)\))?" r":? " r"(?P<subject>[^\n]+):?" r"(\n\n(?P<text>.*))?", re.DOTALL, )
[docs] @staticmethod def get_default_options() -> ScipyParserOptions: return ScipyParserOptions()
[docs] def parse(self, commit: Commit) -> ParseResult: message = str(commit.message) parsed = self.re_parser.match(message) if not parsed: return _logged_parse_error( commit, f"Unable to parse the given commit message: {message}" ) if parsed.group("subject"): subject = parsed.group("subject") else: return _logged_parse_error(commit, f"Commit has no subject {message!r}") if parsed.group("text"): blocks = parsed.group("text").split("\n\n") blocks = [x for x in blocks if x] blocks.insert(0, subject) else: blocks = [subject] for tag in self.options.allowed_tags: if tag == parsed.group("tag"): section = tag_to_section.get(tag, "None") level_bump = self.options.tag_to_level.get( tag, self.options.default_level_bump ) log.debug( "commit %s introduces a %s level_bump", commit.hexsha, level_bump ) break else: # some commits may not have a tag, e.g. if they belong to a PR that # wasn't squashed (for maintainability) ignore them section, level_bump = "None", self.options.default_level_bump log.debug( "commit %s introduces a level bump of %s due to the default_bump_level", commit.hexsha, level_bump, ) # Look for descriptions of breaking changes migration_instructions = [ block for block in blocks if block.startswith("BREAKING CHANGE") ] if migration_instructions: level_bump = LevelBump.MAJOR log.debug( "commit %s upgraded to a %s level_bump due to migration_instructions", commit.hexsha, level_bump, ) return ParsedCommit( bump=level_bump, type=section, scope=parsed.group("scope"), descriptions=blocks, breaking_descriptions=migration_instructions, commit=commit, )