diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 60b739fd1..2911ae48f 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -2,11 +2,17 @@ import logging import os -import re from collections.abc import Mapping from dataclasses import dataclass, is_dataclass from enum import Enum +from functools import reduce from pathlib import Path +from re import ( + Pattern, + compile as regexp, + error as RegExpError, # noqa: N812 + escape as regex_escape, +) from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Type, Union from git import Actor, InvalidGitRepositoryError @@ -157,6 +163,20 @@ class ChangelogConfig(BaseModel): insertion_flag: str = "" template_dir: str = "templates" + @field_validator("exclude_commit_patterns", mode="after") + @classmethod + def validate_match(cls, patterns: Tuple[str, ...]) -> Tuple[str, ...]: + curr_index = 0 + try: + for i, pattern in enumerate(patterns): + curr_index = i + regexp(pattern) + except RegExpError as err: + raise ValueError( + f"exclude_commit_patterns[{curr_index}]: Invalid regular expression" + ) from err + return patterns + @field_validator("changelog_file", mode="after") @classmethod def changelog_file_deprecation_warning(cls, val: str) -> str: @@ -228,8 +248,8 @@ def validate_match(cls, match: str) -> str: return ".*" try: - re.compile(match) - except re.error as err: + regexp(match) + except RegExpError as err: raise ValueError(f"Invalid regex {match!r}") from err return match @@ -513,7 +533,7 @@ class RuntimeContext: assets: List[str] commit_author: Actor commit_message: str - changelog_excluded_commit_patterns: Tuple[re.Pattern[str], ...] + changelog_excluded_commit_patterns: Tuple[Pattern[str], ...] version_declarations: Tuple[VersionDeclarationABC, ...] hvcs_client: hvcs.HvcsBase changelog_insertion_flag: str @@ -545,7 +565,7 @@ def select_branch_options( choices: Dict[str, BranchConfig], active_branch: str ) -> BranchConfig: for group, options in choices.items(): - if re.match(options.match, active_branch): + if regexp(options.match).match(active_branch): log.info( "Using group %r options, as %r matches %r", group, @@ -639,12 +659,21 @@ def from_raw_config( # noqa: C901 # We always exclude PSR's own release commits from the Changelog # when parsing commits - _psr_release_commit_re = re.compile( - raw.commit_message.replace(r"{version}", r"(?P.*)") + psr_release_commit_regex = regexp( + reduce( + lambda regex_str, pattern: str(regex_str).replace(*pattern), + ( + # replace the version holder with a regex pattern to match various versions + (regex_escape("{version}"), r"(?P\d+\.\d+\.\d+\S*)"), + # TODO: add any other placeholders here + ), + # We use re.escape to ensure that the commit message is treated as a literal + regex_escape(raw.commit_message), + ) ) changelog_excluded_commit_patterns = ( - _psr_release_commit_re, - *(re.compile(pattern) for pattern in raw.changelog.exclude_commit_patterns), + psr_release_commit_regex, + *(regexp(pattern) for pattern in raw.changelog.exclude_commit_patterns), ) _commit_author_str = cls.resolve_from_env(raw.commit_author) or "" diff --git a/tests/e2e/cmd_version/bump_version/conftest.py b/tests/e2e/cmd_version/bump_version/conftest.py index ecec668a9..23924b579 100644 --- a/tests/e2e/cmd_version/bump_version/conftest.py +++ b/tests/e2e/cmd_version/bump_version/conftest.py @@ -1,8 +1,6 @@ from __future__ import annotations -import os import shutil -from re import IGNORECASE, compile as regexp from typing import TYPE_CHECKING import pytest @@ -10,17 +8,10 @@ if TYPE_CHECKING: from pathlib import Path - from re import Pattern from typing import Protocol from tests.fixtures.git_repo import BuildRepoFromDefinitionFn, RepoActionConfigure - class GetSanitizedMdChangelogContentFn(Protocol): - def __call__(self, repo_dir: Path) -> str: ... - - class GetSanitizedRstChangelogContentFn(Protocol): - def __call__(self, repo_dir: Path) -> str: ... - class InitMirrorRepo4RebuildFn(Protocol): def __call__( self, @@ -67,69 +58,3 @@ def _init_mirror_repo_for_rebuild( return mirror_repo_dir return _init_mirror_repo_for_rebuild - - -@pytest.fixture(scope="session") -def long_hash_pattern() -> Pattern: - return regexp(r"\b([0-9a-f]{40})\b", IGNORECASE) - - -@pytest.fixture(scope="session") -def short_hash_pattern() -> Pattern: - return regexp(r"\b([0-9a-f]{7})\b", IGNORECASE) - - -@pytest.fixture(scope="session") -def get_sanitized_rst_changelog_content( - changelog_rst_file: Path, - default_rst_changelog_insertion_flag: str, - long_hash_pattern: Pattern, - short_hash_pattern: Pattern, -) -> GetSanitizedRstChangelogContentFn: - rst_short_hash_link_pattern = regexp(r"(_[0-9a-f]{7})\b", IGNORECASE) - - def _get_sanitized_rst_changelog_content(repo_dir: Path) -> str: - # TODO: v10 change -- default turns to update so this is not needed - # Because we are in init mode, the insertion flag is not present in the changelog - # we must take it out manually because our repo generation fixture includes it automatically - with (repo_dir / changelog_rst_file).open(newline=os.linesep) as rfd: - # use os.linesep here because the insertion flag is os-specific - # but convert the content to universal newlines for comparison - changelog_content = ( - rfd.read() - .replace(f"{default_rst_changelog_insertion_flag}{os.linesep}", "") - .replace("\r", "") - ) - - changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) - changelog_content = short_hash_pattern.sub("0" * 7, changelog_content) - return rst_short_hash_link_pattern.sub(f'_{"0" * 7}', changelog_content) - - return _get_sanitized_rst_changelog_content - - -@pytest.fixture(scope="session") -def get_sanitized_md_changelog_content( - changelog_md_file: Path, - default_md_changelog_insertion_flag: str, - long_hash_pattern: Pattern, - short_hash_pattern: Pattern, -) -> GetSanitizedMdChangelogContentFn: - def _get_sanitized_md_changelog_content(repo_dir: Path) -> str: - # TODO: v10 change -- default turns to update so this is not needed - # Because we are in init mode, the insertion flag is not present in the changelog - # we must take it out manually because our repo generation fixture includes it automatically - with (repo_dir / changelog_md_file).open(newline=os.linesep) as rfd: - # use os.linesep here because the insertion flag is os-specific - # but convert the content to universal newlines for comparison - changelog_content = ( - rfd.read() - .replace(f"{default_md_changelog_insertion_flag}{os.linesep}", "") - .replace("\r", "") - ) - - changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) - - return short_hash_pattern.sub("0" * 7, changelog_content) - - return _get_sanitized_md_changelog_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py index 7a81959e1..0c753051c 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_gitflow_repo_rebuild_1_channel( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py index 72099fc0d..004206ad4 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_gitflow_repo_rebuild_2_channels( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py index e38df0189..2bfa71f03 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -28,11 +28,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -73,8 +70,8 @@ def test_gitflow_repo_rebuild_3_channels( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py index 6862bc54e..95464cb0f 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_gitflow_repo_rebuild_4_channels( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py index 99c111f39..365672843 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_githubflow_repo_rebuild_1_channel( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py index a3710e2e7..45b29f082 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_githubflow_repo_rebuild_2_channels( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py index 6d0586edb..fbb876761 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_trunk_repo_rebuild_only_official_releases( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py index 236b22209..e81ba67ef 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py @@ -28,11 +28,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -72,8 +69,8 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py index 40981b8fe..53544a058 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py @@ -28,11 +28,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -72,8 +69,8 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py index 5057bab47..e907fb2f0 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -27,11 +27,8 @@ from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import ( - GetSanitizedMdChangelogContentFn, - GetSanitizedRstChangelogContentFn, - InitMirrorRepo4RebuildFn, - ) + from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( BuildRepoFromDefinitionFn, @@ -71,8 +68,8 @@ def test_trunk_repo_rebuild_w_prereleases( post_mocker: Mocker, default_tag_format_str: str, version_py_file: Path, - get_sanitized_md_changelog_content: GetSanitizedMdChangelogContentFn, - get_sanitized_rst_changelog_content: GetSanitizedRstChangelogContentFn, + get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, + get_sanitized_rst_changelog_content: GetSanitizedChangelogContentFn, ): # build target repo into a temporary directory target_repo_dir = example_project_dir / repo_fixture_name diff --git a/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py b/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py new file mode 100644 index 000000000..0351f5dfb --- /dev/null +++ b/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from os import remove as delete_file +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest +from freezegun import freeze_time +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.changelog.context import ChangelogMode +from semantic_release.cli.commands.main import main + +from tests.const import ( + MAIN_PROG_NAME, + VERSION_SUBCMD, +) +from tests.e2e.conftest import ( + get_sanitized_md_changelog_content, + get_sanitized_rst_changelog_content, +) +from tests.fixtures.example_project import ( + changelog_md_file, + changelog_rst_file, +) +from tests.fixtures.repos import ( + repo_w_trunk_only_angular_commits, +) +from tests.util import ( + assert_successful_exit_code, +) + +if TYPE_CHECKING: + from pathlib import Path + from typing import TypedDict + + from click.testing import CliRunner + + from tests.conftest import GetStableDateNowFn + from tests.e2e.conftest import GetSanitizedChangelogContentFn + from tests.fixtures.example_project import UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildRepoFromDefinitionFn, + BuiltRepoResult, + CommitDef, + GetCfgValueFromDefFn, + GetVersionsFromRepoBuildDefFn, + RepoActions, + SplitRepoActionsByReleaseTagsFn, + ) + + class Commit2Section(TypedDict): + angular: Commit2SectionCommit + emoji: Commit2SectionCommit + scipy: Commit2SectionCommit + + class Commit2SectionCommit(TypedDict): + commit: CommitDef + section: str + + +@pytest.mark.parametrize( + str.join( + ", ", + [ + "custom_commit_message", + "changelog_mode", + "changelog_file", + "get_sanitized_changelog_content", + "repo_result", + "cache_key", + ], + ), + [ + pytest.param( + custom_commit_message, + changelog_mode, + lazy_fixture(changelog_file), + lazy_fixture(cl_sanitizer), + lazy_fixture(repo_fixture_name), + f"psr/repos/{repo_fixture_name}", + marks=pytest.mark.comprehensive, + ) + for changelog_mode in [ChangelogMode.INIT, ChangelogMode.UPDATE] + for changelog_file, cl_sanitizer in [ + ( + changelog_md_file.__name__, + get_sanitized_md_changelog_content.__name__, + ), + ( + changelog_rst_file.__name__, + get_sanitized_rst_changelog_content.__name__, + ), + ] + for repo_fixture_name, custom_commit_message in [ + *[ + ( + # Repos: Must have at least 2 releases + repo_w_trunk_only_angular_commits.__name__, + commit_msg, + ) + for commit_msg in [ + dedent( + # Angular compliant prefix with skip-ci idicator + """\ + chore(release): v{version} [skip ci] + + Automatically generated by python-semantic-release. + """ + ), + ] + ], + ] + ], +) +def test_version_changelog_content_custom_commit_message_excluded_automatically( + repo_result: BuiltRepoResult, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + get_cfg_value_from_def: GetCfgValueFromDefFn, + split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, + build_repo_from_definition: BuildRepoFromDefinitionFn, + cli_runner: CliRunner, + update_pyproject_toml: UpdatePyprojectTomlFn, + changelog_file: Path, + changelog_mode: ChangelogMode, + custom_commit_message: str, + cache: pytest.Cache, + cache_key: str, + stable_now_date: GetStableDateNowFn, + example_project_dir: Path, + get_sanitized_changelog_content: GetSanitizedChangelogContentFn, +): + """ + Given a repo with a custom release commit message + When the version subcommand is invoked with the changelog flag + Then the resulting changelog content should not include the + custom commit message + + It should work regardless of changelog mode and changelog file type + """ + expected_changelog_content = get_sanitized_changelog_content( + repo_dir=example_project_dir, + remove_insertion_flag=bool(changelog_mode == ChangelogMode.INIT), + ) + + repo = repo_result["repo"] + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + all_versions = get_versions_from_repo_build_def(repo_def) + latest_tag = tag_format_str.format(version=all_versions[-1]) + previous_tag = tag_format_str.format(version=all_versions[-2]) + + # split repo actions by release actions + releasetags_2_steps: dict[str, list[RepoActions]] = ( + split_repo_actions_by_release_tags(repo_def, tag_format_str) + ) + + # Reverse release to make the previous version again with the new commit message + repo.git.tag("-d", latest_tag) + repo.git.reset("--hard", f"{previous_tag}~1") + repo.git.tag("-d", previous_tag) + + # Set the project configurations + update_pyproject_toml("tool.semantic_release.changelog.mode", changelog_mode.value) + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + str(changelog_file.name), + ) + update_pyproject_toml( + "tool.semantic_release.commit_message", + custom_commit_message, + ) + + if not (repo_build_data := cache.get(cache_key, None)): + pytest.fail("Repo build date not found in cache") + + repo_build_datetime = datetime.strptime(repo_build_data["build_date"], "%Y-%m-%d") + now_datetime = stable_now_date().replace( + year=repo_build_datetime.year, + month=repo_build_datetime.month, + day=repo_build_datetime.day, + ) + + if changelog_mode == ChangelogMode.UPDATE and len(all_versions) == 2: + # When in update mode, and at the very first release, its better the + # changelog file does not exist as we have an non-conformative example changelog + # in the base example project + delete_file(example_project_dir / changelog_file) + + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] + + # Act: make the first release again + with freeze_time(now_datetime.astimezone(timezone.utc)): + result = cli_runner.invoke(main, cli_cmd[1:]) + assert_successful_exit_code(result, cli_cmd) + + # Act: apply commits for change of version + steps_for_next_release = releasetags_2_steps[latest_tag][ + :-1 + ] # stop before the release step + build_repo_from_definition( + dest_dir=example_project_dir, + repo_construction_steps=steps_for_next_release, + ) + + # Act: make the second release again + with freeze_time(now_datetime.astimezone(timezone.utc) + timedelta(minutes=1)): + result = cli_runner.invoke(main, cli_cmd[1:]) + + actual_content = get_sanitized_changelog_content( + repo_dir=example_project_dir, + remove_insertion_flag=bool(changelog_mode == ChangelogMode.INIT), + ) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert expected_changelog_content == actual_content diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 001692780..66aa8ab3d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,6 +2,7 @@ import os from pathlib import Path +from re import IGNORECASE, compile as regexp from typing import TYPE_CHECKING from unittest.mock import MagicMock @@ -20,6 +21,7 @@ from tests.util import prepare_mocked_git_command_wrapper_type if TYPE_CHECKING: + from re import Pattern from typing import Protocol from git.repo import Repo @@ -28,6 +30,13 @@ from tests.fixtures.example_project import ExProjectDir + class GetSanitizedChangelogContentFn(Protocol): + def __call__( + self, + repo_dir: Path, + remove_insertion_flag: bool = True, + ) -> str: ... + class ReadConfigFileFn(Protocol): """Read the raw config file from `config_path`.""" @@ -105,3 +114,78 @@ def _retrieve_runtime_context(repo: Repo) -> RuntimeContext: os.chdir(cwd) return _retrieve_runtime_context + + +@pytest.fixture(scope="session") +def long_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{40})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def short_hash_pattern() -> Pattern: + return regexp(r"\b([0-9a-f]{7})\b", IGNORECASE) + + +@pytest.fixture(scope="session") +def get_sanitized_rst_changelog_content( + changelog_rst_file: Path, + default_rst_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedChangelogContentFn: + rst_short_hash_link_pattern = regexp(r"(_[0-9a-f]{7})\b", IGNORECASE) + + def _get_sanitized_rst_changelog_content( + repo_dir: Path, + remove_insertion_flag: bool = True, + ) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_rst_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read().replace( + f"{default_rst_changelog_insertion_flag}{os.linesep}", "" + ) + if remove_insertion_flag + else rfd.read() + ).replace("\r", "") + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + changelog_content = short_hash_pattern.sub("0" * 7, changelog_content) + return rst_short_hash_link_pattern.sub(f'_{"0" * 7}', changelog_content) + + return _get_sanitized_rst_changelog_content + + +@pytest.fixture(scope="session") +def get_sanitized_md_changelog_content( + changelog_md_file: Path, + default_md_changelog_insertion_flag: str, + long_hash_pattern: Pattern, + short_hash_pattern: Pattern, +) -> GetSanitizedChangelogContentFn: + def _get_sanitized_md_changelog_content( + repo_dir: Path, + remove_insertion_flag: bool = True, + ) -> str: + # TODO: v10 change -- default turns to update so this is not needed + # Because we are in init mode, the insertion flag is not present in the changelog + # we must take it out manually because our repo generation fixture includes it automatically + with (repo_dir / changelog_md_file).open(newline=os.linesep) as rfd: + # use os.linesep here because the insertion flag is os-specific + # but convert the content to universal newlines for comparison + changelog_content = ( + rfd.read().replace( + f"{default_md_changelog_insertion_flag}{os.linesep}", "" + ) + if remove_insertion_flag + else rfd.read() + ).replace("\r", "") + + changelog_content = long_hash_pattern.sub("0" * 40, changelog_content) + return short_hash_pattern.sub("0" * 7, changelog_content) + + return _get_sanitized_md_changelog_content diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index 369958290..d76456e7f 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from re import compile as regexp from typing import TYPE_CHECKING from unittest import mock @@ -307,6 +308,58 @@ def test_branch_config_with_invalid_regex(invalid_regex: str): ) +@pytest.mark.parametrize( + "valid_patterns", + [ + # Single entry + [r"chore(?:\([^)]*?\))?: .+"], + # Multiple entries + [r"^\d+\.\d+\.\d+", r"Initial [Cc]ommit.*"], + ], +) +def test_changelog_config_with_valid_exclude_commit_patterns(valid_patterns: list[str]): + assert ChangelogConfig.model_validate( + { + "exclude_commit_patterns": valid_patterns, + } + ) + + +@pytest.mark.parametrize( + "invalid_patterns, index_of_invalid_pattern", + [ + # Single entry, single incorrect + (["*abc"], 0), + # Two entries, second incorrect + ([".*", "[a-z"], 1), + # Two entries, first incorrect + (["(.+", ".*"], 0), + ], +) +def test_changelog_config_with_invalid_exclude_commit_patterns( + invalid_patterns: list[str], + index_of_invalid_pattern: int, +): + with pytest.raises( + ValidationError, + match=regexp( + str.join( + "", + [ + r".*\bexclude_commit_patterns\[", + str(index_of_invalid_pattern), + r"\]: Invalid regular expression", + ], + ), + ), + ): + ChangelogConfig.model_validate( + { + "exclude_commit_patterns": invalid_patterns, + } + ) + + @pytest.mark.parametrize( "output_format, insertion_flag", [