Repository: relekang/python-semantic-release
Pull request #369 (2021-08-07 06:43:51)

feat(parser): add scipy style parser
Closes: #368 

Adds a simple scipy style parser + unit tests

The tests generate a matrix of commit messages (from parts that I faithfully borrowed from my repos and scipy), and they parse correctly.

When I naively run the parser on my target repo (`semantic release -v debug changelog` in project root), however, the changelog remains empty. I can confirm that it indeed selects the scipy_parser added here, and I know that at least some of the commits should be parsed correctly (they are generated by the tests). Does python-semantic-release need an initial tagged commit to get going? If not, any other idea where I am being stupid?

**Edit:** The version appears to be detected correctly:

```bash
$ semantic-release -v debug --noop version
debug: Main args: {'force_level': None, 'post': False, 'retry': False, 'noop': False, 'define': ()}
debug: Environment: PYPI_USERNAME="None",PYPI_PASSWORD="None",GH_TOKEN="None",GL_TOKEN="None",
debug: Main config: {'check_build_status': False, 'commit_subject': '{version}', 'commit_message': 'Automatically generated by python-semantic-release', 'commit_parser': 'semantic_release.history.scipy_parser', 'patch_without_tag': False, 'major_on_zero': True, 'upload_to_pypi': True, 'version_source': 'commit', 'no_git_tag': None}
Creating new version
Current version: 0.0.2
warning: No operation mode. Should have bumped from 0.0.2 to 1.0.0
```

FirefoxMetzger: This branch is RTR.

I managed to fix my problem with generating changelogs via `semantic-release changelog`, which generates the expected sectioned bullet-point list which is printed into the console. I'm now wondering how I can get a version name and date as a section title and make it actually write the changelog, but I'm sure that is again just user-error on my part.

Patch:
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))