Repository: relekang/python-semantic-release
Pull request #370 (2021-08-07 10:32:53)

doc: clarify second argument of ParsedCommit


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7abe97..a1c7e9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 <!--next-version-placeholder-->
 
+## v7.17.0 (2021-08-07)
+### Feature
+* **parser:** Add scipy style parser ([#369](https://github.com/relekang/python-semantic-release/issues/369)) ([`51a3921`](https://github.com/relekang/python-semantic-release/commit/51a39213ea120c4bbd7a57b74d4f0cc3103da9f5))
+
 ## v7.16.4 (2021-08-03)
 ### Fix
 * Correct rendering of gitlab issue references ([`07429ec`](https://github.com/relekang/python-semantic-release/commit/07429ec4a32d32069f25ec77b4bea963bd5d2a00))
diff --git a/docs/commit-log-parsing.rst b/docs/commit-log-parsing.rst
index b7b88e7..ebe36f3 100644
--- a/docs/commit-log-parsing.rst
+++ b/docs/commit-log-parsing.rst
@@ -45,8 +45,8 @@ of the output should be a :py:class:`semantic_release.history.parser_helpers.Par
 object with the following parameters::
 
     ParsedCommit(
-      level to bump: major=3 minor=2 patch=1 none=0,
-      type of change,
+      version level to bump: major=3 minor=2 patch=1 none=0,
+      changelog section (see: :ref:`config-changelog_sections`),
       scope of change: can be None,
       (subject, descriptions...),
       (breaking change descriptions...)
diff --git a/semantic_release/__init__.py b/semantic_release/__init__.py
index 2c89f11..33fa7a7 100644
--- a/semantic_release/__init__.py
+++ b/semantic_release/__init__.py
@@ -1,6 +1,6 @@
 """Semantic Release
 """
-__version__ = "7.16.4"
+__version__ = "7.17.0"
 
 
 from .errors import UnknownCommitMessageStyleError  # noqa; noqa
diff --git a/semantic_release/history/__init__.py b/semantic_release/history/__init__.py
index a069fc0..60a96e2 100644
--- a/semantic_release/history/__init__.py
+++ b/semantic_release/history/__init__.py
@@ -19,6 +19,7 @@ from .logs import evaluate_version_bump  # noqa
 
 from .parser_angular import parse_commit_message as angular_parser  # noqa isort:skip
 from .parser_emoji import parse_commit_message as emoji_parser  # noqa isort:skip
+from .parser_scipy import parse_commit_message as scipy_parser  # noqa isort:skip
 from .parser_tag import parse_commit_message as tag_parser  # noqa isort:skip
 
 logger = logging.getLogger(__name__)
diff --git a/semantic_release/history/parser_scipy.py b/semantic_release/history/parser_scipy.py
new file mode 100644
index 0000000..1342412
--- /dev/null
+++ b/semantic_release/history/parser_scipy.py
@@ -0,0 +1,167 @@
+"""
+Scipy Style Parser
+------------------
+
+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.
+
+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. This section 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
+
+.. note::
+    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>
+
+.. _`scipy-style`: https://docs.scipy.org/doc/scipy/reference/dev/contributor/development_workflow.html#writing-the-commit-message
+"""
+
+import logging
+import re
+
+from ..errors import UnknownCommitMessageStyleError
+from ..helpers import LoggedFunction
+from .parser_helpers import ParsedCommit
+
+logger = logging.getLogger(__name__)
+
+
+class ChangeType:
+    def __init__(self, tag, section) -> None:
+        self.tag: str = tag
+        self.section: str = section
+        self.bump_level: int = 0
+
+    def make_breaking(self):
+        self.bump_level = 3
+
+
+class Breaking(ChangeType):
+    def __init__(self, tag, section) -> None:
+        super().__init__(tag, section)
+        self.bump_level: int = 3
+
+
+class Compatible(ChangeType):
+    def __init__(self, tag, section) -> None:
+        super().__init__(tag, section)
+        self.bump_level: int = 2
+
+
+class Patch(ChangeType):
+    def __init__(self, tag, section) -> None:
+        super().__init__(tag, section)
+        self.bump_level: int = 1
+
+
+class Ignore(ChangeType):
+    def __init__(self, tag, section) -> None:
+        super().__init__(tag, section)
+        self.bump_level: int = 0
+
+
+COMMIT_TYPES = [
+    Breaking("API", "breaking"),
+    Ignore("BENCH", "None"),
+    Patch("BLD", "fix"),
+    Patch("BUG", "fix"),
+    Compatible("DEP", "breaking"),
+    Compatible("DEV", "None"),
+    Ignore("DOC", "documentation"),
+    Compatible("ENH", "feature"),
+    Patch("MAINT", "fix"),
+    Compatible("REV", "Other"),
+    Ignore("STY", "None"),
+    Ignore("TST", "None"),
+    Ignore("REL", "None"),
+    # strictly speaking not part of the standard
+    Compatible("FEAT", "feature"),
+    Ignore("TEST", "None"),
+]
+
+_commit_filter = "|".join(c.tag for c in COMMIT_TYPES)
+re_parser = re.compile(
+    fr"(?P<tag>{_commit_filter})?"
+    r"(?:\((?P<scope>[^\n]+)\))?"
+    r":? "
+    r"(?P<subject>[^\n]+):?"
+    r"(\n\n(?P<text>.*))?",
+    re.DOTALL,
+)
+
+
+@LoggedFunction(logger)
+def parse_commit_message(message: str) -> ParsedCommit:
+    """
+    Parse a scipy-style commit message
+
+    :param message: A string of a commit message.
+    :return: A tuple of (level to bump, type of change, scope of change, a tuple
+    with descriptions)
+    :raises UnknownCommitMessageStyleError: if regular expression matching fails
+    """
+
+    parsed = re_parser.match(message)
+    if not parsed:
+        raise UnknownCommitMessageStyleError(
+            f"Unable to parse the given commit message: {message}"
+        )
+
+    if parsed.group("subject"):
+        subject = parsed.group("subject")
+    else:
+        raise UnknownCommitMessageStyleError(f"The commit has no subject {message}")
+
+    if parsed.group("text"):
+        blocks = parsed.group("text").split("\n\n")
+        blocks = [x for x in blocks if not x == ""]
+        blocks.insert(0, subject)
+    else:
+        blocks = [subject]
+
+    msg_type: ChangeType
+    for msg_type in COMMIT_TYPES:
+        if msg_type.tag == parsed.group("tag"):
+            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
+        msg_type = Ignore("", "None")
+
+    # Look for descriptions of breaking changes
+    migration_instructions = [
+        block for block in blocks if block.startswith("BREAKING CHANGE")
+    ]
+    if migration_instructions:
+        msg_type.make_breaking()
+
+    return ParsedCommit(
+        msg_type.bump_level,
+        msg_type.section,
+        parsed.group("scope"),
+        blocks,
+        migration_instructions,
+    )


Test patch:
diff --git a/tests/parsers/conftest.py b/tests/parsers/conftest.py
new file mode 100644
index 0000000..cd1ef4b
--- /dev/null
+++ b/tests/parsers/conftest.py
@@ -0,0 +1,72 @@
+import pytest
+
+from semantic_release.history.parser_scipy import COMMIT_TYPES, ChangeType
+
+
+@pytest.fixture(params=COMMIT_TYPES)
+def scipy_type(request):
+    return request.param
+
+
+@pytest.fixture(
+    params=[
+        "scipy.stats.qmc: centered-discrepancy optimization of a Latin hypercube",
+        "inverse missing in idstn, idctn (#14479)",
+        "Merge pull request #14447 from AnirudhDagar/rename_ndimage_modules",
+        "Add tests for args kwarg in quad_vec",
+        "badge with version of the doc in the navbar (#14132)",
+        "Bump scipy from 1.7.0 to 1.7.1 (#28)",
+        "avoid nan if angle=0 in RotvecRotation",
+    ]
+)
+def subject(request):
+    return request.param
+
+
+@pytest.fixture(
+    params=[
+        # a squash merge that preserved PR commit messages
+        (
+            "DOC: import ropy.transform to test for numpy error",
+            "DOC: lower numpy version",
+            "DOC: lower numpy version further",
+            "MAINT: remove debugging import",
+        ),
+        # empty body
+        list(),
+        # formatted body
+        (
+            """Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 3.5.3 to 4.1.1.
+            - [Release notes](https://github.com/sphinx-doc/sphinx/releases)
+            - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES)
+            - [Commits](https://github.com/sphinx-doc/sphinx/commits/v4.1.1)""",
+            """---
+            updated-dependencies:
+            - dependency-name: sphinx
+            dependency-type: direct:development
+            update-type: version-update:semver-major""",
+        ),
+        (
+            "Bug spotted on Fedora, see https://src.fedoraproject.org/rpms/scipy/pull-request/22",
+            "The `int[::]` annotation is used to accept non-contiguous views.",
+        ),
+        ("[skip azp] [skip actions]",),
+    ]
+)
+def body_parts(request):
+    return request.param
+
+
+@pytest.fixture()
+def expected_response_scipy(scipy_type: ChangeType, subject, body_parts):
+    bump_level = scipy_type.bump_level
+    topic = scipy_type.section
+    changelog_body = (subject, *body_parts)
+    return (bump_level, topic, None, changelog_body)
+
+
+@pytest.fixture()
+def valid_scipy_commit(scipy_type: ChangeType, subject, body_parts):
+    body = "\n\n".join(body_parts)
+    commit_msg = f"{scipy_type.tag}: {subject}\n\n{body}"
+    return commit_msg
diff --git a/tests/parsers/test_scipy.py b/tests/parsers/test_scipy.py
new file mode 100644
index 0000000..e338533
--- /dev/null
+++ b/tests/parsers/test_scipy.py
@@ -0,0 +1,14 @@
+import pytest
+
+from semantic_release.errors import UnknownCommitMessageStyleError
+from semantic_release.history import scipy_parser
+
+
+def test_valid_scipy_commit(valid_scipy_commit, expected_response_scipy):
+    (commit_tag, subject, _, body_parts) = expected_response_scipy
+    result = scipy_parser(valid_scipy_commit)
+
+    assert result[0] == commit_tag
+    assert result[1] == subject
+    assert len(result[3]) == len(body_parts)
+    assert all(a == b for a, b in zip(result[3], body_parts))