From 52f53d899c8ae4cb0ea0e7605fc27744428a531b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 20:49:51 -0700 Subject: [PATCH 01/10] ci(deps): bump `psr/publish-action` & `action-junit-report` action versions (#1126) * ci(deps): bump `python-semantic-release/publish-action` from 9.15.1 to 9.15.2 * ci(deps): bump `mikepenz/action-junit-report` from 5.1.0 to 5.2.0 --- .github/workflows/cicd.yml | 2 +- .github/workflows/validate.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2e82b4ef6..f1e79f4c4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -125,7 +125,7 @@ jobs: build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.15.1 + uses: python-semantic-release/publish-action@v9.15.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 163840e5f..e4588b52a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -157,7 +157,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.1.0 + uses: mikepenz/action-junit-report@v5.2.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -245,7 +245,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.1.0 + uses: mikepenz/action-junit-report@v5.2.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -340,7 +340,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.1.0 + uses: mikepenz/action-junit-report@v5.2.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml From 297e11f63d12d43b066110d9f1bee6c2bfa685b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Dec 2024 07:34:06 -0700 Subject: [PATCH 02/10] build(deps-dev): bump `mypy` from 1.13.0 to 1.14.0 (#1130) * build(deps-dev): bump `mypy` from 1.13.0 to 1.14.0 * chore(pre-commit): bump `mypy` plugin from 1.13.0 to 1.14.0 --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: codejedi365 --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d1f5b795..70e871f44 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: name: ruff (format) - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.13.0" + rev: "v1.14.0" hooks: - id: mypy additional_dependencies: diff --git a/pyproject.toml b/pyproject.toml index b436bbda6..516308ae7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ "ruff == 0.6.1" ] mypy = [ - "mypy == 1.13.0", + "mypy == 1.14.0", "types-requests ~= 2.32.0", "types-pyyaml ~= 6.0", ] From 06de1f981dcd853216ded671a1aa68f19c059855 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 11 Jan 2025 00:42:56 -0700 Subject: [PATCH 03/10] ci(validate-wkflow): fix windows test failure upload repo failure --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e4588b52a..62859ad06 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -334,7 +334,7 @@ jobs: if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} - path: /tmp/pytest-of-runner/pytest-current/* + path: ~/AppData/Local/Temp/pytest-of-runneradmin/pytest-current/* include-hidden-files: true if-no-files-found: error retention-days: 1 From 4b310225b81f6f05f243705cad97d3ef2053b283 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jan 2025 01:18:11 -0700 Subject: [PATCH 04/10] build(deps-dev): bump `mypy` from 1.14.0 to 1.14.1 (#1131) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 516308ae7..107a8bfc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dev = [ "ruff == 0.6.1" ] mypy = [ - "mypy == 1.14.0", + "mypy == 1.14.1", "types-requests ~= 2.32.0", "types-pyyaml ~= 6.0", ] From 33b9371e2d52c637bf31e7ee8693d70fbdab4c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Jan 2025 01:22:14 -0700 Subject: [PATCH 05/10] ci(deps): bump `tj-actions/changed-files` action from 45.0.5 to 45.0.6 (#1133) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/cicd.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8f87da69..89619f2e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,13 +41,13 @@ jobs: - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@v45.0.5 + uses: tj-actions/changed-files@v45.0.6 with: files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@v45.0.5 + uses: tj-actions/changed-files@v45.0.6 with: files_yaml: | ci: diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index f1e79f4c4..14e662112 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -26,14 +26,14 @@ jobs: - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@v45.0.5 + uses: tj-actions/changed-files@v45.0.6 with: base_sha: ${{ github.event.push.before }} files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@v45.0.5 + uses: tj-actions/changed-files@v45.0.6 with: base_sha: ${{ github.event.push.before }} files_yaml: | From a990aa7ab0a9d52d295c04d54d20e9c9f2db2ca5 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 11 Jan 2025 01:25:36 -0700 Subject: [PATCH 06/10] fix(cmd-version): fix `--print-tag` result to match configured tag format (#1134) * test(fixtures): add new trunk repo that has a different tag format * test(fixtures): add helper to extract config settings from repo action definition * test(cmd-version): expand testing of `--print-tag` & `--print-last-released-tag` PSR did not have enough testing to demonstrate testing of the tag generation when the tag format was configured differently than normal. This commit adds a significant portion of testing to exercise the print tag functionality which must match the configured tag format. --- src/semantic_release/cli/commands/version.py | 8 +- tests/e2e/cmd_version/test_version_print.py | 375 +++++++++++++++++- tests/fixtures/git_repo.py | 33 +- .../repos/trunk_based_dev/repo_w_tags.py | 39 ++ 4 files changed, 437 insertions(+), 18 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 737d13d8d..2342fe6f7 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -516,12 +516,8 @@ def version( # noqa: C901 gha_output.version = new_version ctx.call_on_close(gha_output.write_if_possible) - # Make string variant of version && Translate to tag if necessary - version_to_print = ( - str(new_version) - if not print_only_tag - else translator.str_to_tag(str(new_version)) - ) + # Make string variant of version or appropriate tag as necessary + version_to_print = str(new_version) if not print_only_tag else new_version.as_tag() # Print the new version so that command-line output capture will work click.echo(version_to_print) diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 8d22cbf23..922160358 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -14,8 +14,10 @@ from tests.fixtures.commit_parsers import angular_minor_commits from tests.fixtures.git_repo import get_commit_def_of_angular_commit from tests.fixtures.repos import ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format, repo_w_no_tags_angular_commits, repo_w_trunk_only_angular_commits, + repo_w_trunk_only_angular_commits_using_tag_format, ) from tests.util import ( add_text_to_file, @@ -31,6 +33,7 @@ from tests.fixtures.git_repo import ( BuiltRepoResult, + GetCfgValueFromDefFn, GetCommitDefFn, GetVersionsFromRepoBuildDefFn, SimulateChangeCommitsNReturnChangelogEntryFn, @@ -71,7 +74,6 @@ # Forced version bump with --as-prerelease and modified --prerelease-token # and --build-metadata ( - # TODO: Error, our current implementation does not support this [ "--patch", "--as-prerelease", @@ -144,6 +146,120 @@ def test_version_print_next_version( assert post_mocker.call_count == 0 +@pytest.mark.parametrize( + "repo_result, commits, force_args, next_release_version", + [ + ( + lazy_fixture(repo_fixture_name), + lazy_fixture(angular_minor_commits.__name__), + cli_args, + next_release_version, + ) + for repo_fixture_name in ( + repo_w_trunk_only_angular_commits.__name__, + repo_w_trunk_only_angular_commits_using_tag_format.__name__, + ) + for cli_args, next_release_version in ( + # Dynamic version bump determination (based on commits) + ([], "0.2.0"), + # Dynamic version bump determination (based on commits) with build metadata + (["--build-metadata", "build.12345"], "0.2.0+build.12345"), + # Forced version bump + (["--prerelease"], "0.1.1-rc.1"), + (["--patch"], "0.1.2"), + (["--minor"], "0.2.0"), + (["--major"], "1.0.0"), + # Forced version bump with --build-metadata + (["--patch", "--build-metadata", "build.12345"], "0.1.2+build.12345"), + # Forced version bump with --as-prerelease + (["--prerelease", "--as-prerelease"], "0.1.1-rc.1"), + (["--patch", "--as-prerelease"], "0.1.2-rc.1"), + (["--minor", "--as-prerelease"], "0.2.0-rc.1"), + (["--major", "--as-prerelease"], "1.0.0-rc.1"), + # Forced version bump with --as-prerelease and modified --prerelease-token + ( + ["--patch", "--as-prerelease", "--prerelease-token", "beta"], + "0.1.2-beta.1", + ), + # Forced version bump with --as-prerelease and modified --prerelease-token + # and --build-metadata + ( + [ + "--patch", + "--as-prerelease", + "--prerelease-token", + "beta", + "--build-metadata", + "build.12345", + ], + "0.1.2-beta.1+build.12345", + ), + ) + ], +) +def test_version_print_tag_prints_next_tag( + repo_result: BuiltRepoResult, + commits: list[str], + force_args: list[str], + next_release_version: str, + get_cfg_value_from_def: GetCfgValueFromDefFn, + file_in_repo: str, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + """ + Given a generic repository at the latest release version and a subsequent commit, + When running the version command with the --print-tag flag, + Then the expected next release tag should be printed and exit without + making any changes to the repository. + + Note: The point of this test is to only verify that the `--print-tag` flag does not + make any changes to the repository--not to validate if the next version is calculated + correctly per the repository structure (see test_version_release & + test_version_force_level for correctness). + + However, we do validate that --print-tag & a force option and/or --as-prerelease options + work together to print the next release tag correctly but not make a change to the repo. + """ + 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] + next_release_tag = tag_format_str.format(version=next_release_version) + + # Make a commit to ensure we have something to release + # otherwise the "no release will be made" logic will kick in first + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[-1], a=True) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag", *force_args] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{next_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + @pytest.mark.parametrize( "repo_result", [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], @@ -383,19 +499,191 @@ def test_version_print_last_released_on_nonrelease_branch( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], +) +def test_version_print_last_released_tag_prints_correct_tag( + repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + 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] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{latest_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result, commits", + [ + ( + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + ), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + lazy_fixture(angular_minor_commits.__name__), + marks=pytest.mark.comprehensive, + ), + ], +) +def test_version_print_last_released_tag_prints_released_if_commits( + repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + commits: list[str], + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + file_in_repo: str, +): + 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] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) + + # Make a commit so the head is not on the last release + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[0], a=True) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert not result.stderr + assert f"{latest_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result", + [lazy_fixture(repo_w_no_tags_angular_commits.__name__)], +) +def test_version_print_last_released_tag_prints_nothing_if_no_tags( + repo_result: BuiltRepoResult, + cli_runner: CliRunner, + mocked_git_push: MagicMock, + post_mocker: Mocker, + caplog: pytest.LogCaptureFixture, +): + repo = repo_result["repo"] + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_sha_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate (no release actions should have occurred on print) + assert_successful_exit_code(result, cli_cmd) + assert result.stdout == "" + + # must use capture log to see this, because we use the logger to print this message + # not click's output + assert "No release tags found." in caplog.text + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_sha_before == head_after.hexsha # No commit has been made + assert not tags_set_difference # No tag created + assert mocked_git_push.call_count == 0 # no git push of tag or commit + assert post_mocker.call_count == 0 # no vcs release + + +@pytest.mark.parametrize( + "repo_result", + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], ) def test_version_print_last_released_tag_on_detached_head( repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, cli_runner: CliRunner, mocked_git_push: MagicMock, post_mocker: Mocker, ): repo = repo_result["repo"] - latest_release_tag = ( - f"v{get_versions_from_repo_build_def(repo_result['definition'])[-1]}" - ) + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + latest_release_tag = tag_format_str.format(version=latest_release_version) # Setup: put the repo in a detached head state repo.git.checkout("HEAD", detach=True) @@ -430,19 +718,29 @@ def test_version_print_last_released_tag_on_detached_head( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_trunk_only_angular_commits.__name__)], + [ + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + pytest.param( + lazy_fixture( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_angular_commits_using_tag_format.__name__ + ), + marks=pytest.mark.comprehensive, + ), + ], ) def test_version_print_last_released_tag_on_nonrelease_branch( repo_result: BuiltRepoResult, + get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, cli_runner: CliRunner, mocked_git_push: MagicMock, post_mocker: Mocker, ): repo = repo_result["repo"] - last_release_tag = ( - f"v{get_versions_from_repo_build_def(repo_result['definition'])[-1]}" - ) + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + latest_release_version = get_versions_from_repo_build_def(repo_def)[-1] + last_release_tag = tag_format_str.format(version=latest_release_version) # Setup: put the repo on a non-release branch repo.create_head("next").checkout() @@ -532,3 +830,62 @@ def test_version_print_next_version_fails_on_detached_head( assert not tags_set_difference assert mocked_git_push.call_count == 0 assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result, get_commit_def_fn", + [ + ( + lazy_fixture(repo_w_trunk_only_angular_commits.__name__), + lazy_fixture(get_commit_def_of_angular_commit.__name__), + ) + ], +) +def test_version_print_next_tag_fails_on_detached_head( + repo_result: BuiltRepoResult, + cli_runner: CliRunner, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + get_commit_def_fn: GetCommitDefFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + repo = repo_result["repo"] + expected_error_msg = ( + "Detached HEAD state cannot match any release groups; no release will be made" + ) + + # Setup: put the repo in a detached head state + repo.git.checkout("HEAD", detach=True) + + # Setup: make a commit to ensure we have something to release + simulate_change_commits_n_rtn_changelog_entry( + repo, + [get_commit_def_fn("fix: make a patch fix to codebase")], + ) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag"] + result = cli_runner.invoke(main, cli_cmd[1:]) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate (expected -> actual) + assert_exit_code(1, result, cli_cmd) + assert not result.stdout + assert f"{expected_error_msg}\n" == result.stderr + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 3f3fab0f6..0656ec3e2 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -34,7 +34,7 @@ ) if TYPE_CHECKING: - from typing import Generator, Literal, Protocol, Sequence, TypedDict, Union + from typing import Any, Generator, Literal, Protocol, Sequence, TypedDict, Union from tests.fixtures.example_project import UpdateVersionPyFileFn @@ -370,6 +370,11 @@ def __call__( self, repo_definition: Sequence[RepoActions], tag_format_str: str ) -> dict[str, list[RepoActions]]: ... + class GetCfgValueFromDefFn(Protocol): + def __call__( + self, build_definition: Sequence[RepoActions], key: str + ) -> Any: ... + @pytest.fixture(scope="session") def deps_files_4_example_git_project( @@ -1083,7 +1088,11 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c action = step["action"] if action == RepoActionStep.CONFIGURE: - cfg_def: RepoActionConfigureDetails = step["details"] # type: ignore[assignment] + cfg_def: RepoActionConfigureDetails = step_result["details"] # type: ignore[assignment] + + # Make sure the resulting build definition is complete with the default + tag_format_str = cfg_def["tag_format_str"] or default_tag_format_str + cfg_def["tag_format_str"] = tag_format_str _, hvcs = build_configured_base_repo( # type: ignore[assignment] # TODO: fix the type error dest_dir, @@ -1101,7 +1110,6 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c ) # Save configuration details for later steps mask_initial_release = cfg_def["mask_initial_release"] - tag_format_str = cfg_def["tag_format_str"] or default_tag_format_str elif action == RepoActionStep.MAKE_COMMITS: mk_cmts_def: RepoActionMakeCommitsDetails = step_result["details"] # type: ignore[assignment] @@ -1222,6 +1230,25 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c return _build_repo_from_definition +@pytest.fixture(scope="session") +def get_cfg_value_from_def() -> GetCfgValueFromDefFn: + def _get_cfg_value_from_def( + build_definition: Sequence[RepoActions], key: str + ) -> Any: + configure_steps = [ + step + for step in build_definition + if step["action"] == RepoActionStep.CONFIGURE + ] + for step in configure_steps[::-1]: + if key in step["details"]: + return step["details"][key] # type: ignore[literal-required] + + raise ValueError(f"Unable to find configuration key: {key}") + + return _get_cfg_value_from_def + + @pytest.fixture(scope="session") def get_versions_from_repo_build_def() -> GetVersionsFromRepoBuildDefFn: def _get_versions(repo_def: Sequence[RepoActions]) -> Sequence[str]: diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py index fe5e509a8..a79bd11dc 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py @@ -262,6 +262,45 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # --------------------------------------------------------------------------- # +@pytest.fixture +def repo_w_trunk_only_angular_commits_using_tag_format( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_tags: str, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + repo_name = repo_w_trunk_only_angular_commits_using_tag_format.__name__ + commit_type: CommitConvention = ( + repo_name.split("_commits", maxsplit=1)[0].split("_")[-1] # type: ignore[assignment] + ) + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_repo_w_tags( + commit_type=commit_type, + tag_format_str="submod-v{version}", + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_tags, + build_repo_func=_build_repo, + dest_dir=example_project_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return { + "definition": cached_repo_data["build_definition"], + "repo": example_project_git_repo(), + } + + @pytest.fixture def repo_w_trunk_only_angular_commits( build_trunk_only_repo_w_tags: BuildSpecificRepoFn, From 007fd00a3945ed211ece4baab0b79ad93dc018f5 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 11 Jan 2025 02:05:57 -0700 Subject: [PATCH 07/10] fix(cmd-version): fix tag format on default version when force bump for initial release (#1138) Resolves: #1137 * test(fixtures): add new unreleased trunk repo with a different tag format * test(cmd-version): ensure forced bump version on initial release follows tag format ref: #1137 --- src/semantic_release/cli/commands/version.py | 16 +- src/semantic_release/version/algorithm.py | 1 + tests/e2e/cmd_version/test_version_print.py | 159 ++++++++++++------ .../repos/trunk_based_dev/repo_w_no_tags.py | 44 +++++ 4 files changed, 168 insertions(+), 52 deletions(-) diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 2342fe6f7..6dc2774d2 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -26,6 +26,7 @@ from semantic_release.errors import ( BuildDistributionsError, GitCommitEmptyIndexError, + InternalError, UnexpectedResponse, ) from semantic_release.gitproject import GitProject @@ -35,7 +36,6 @@ tags_and_versions, ) from semantic_release.version.translator import VersionTranslator -from semantic_release.version.version import Version if TYPE_CHECKING: # pragma: no cover from pathlib import Path @@ -45,6 +45,7 @@ from semantic_release.cli.cli_context import CliContextObj from semantic_release.version.declaration import VersionDeclarationABC + from semantic_release.version.version import Version log = logging.getLogger(__name__) @@ -90,7 +91,18 @@ def version_from_forced_level( # If we have no tags, return the default version if not ts_and_vs: - return Version.parse(DEFAULT_VERSION).bump(forced_level_bump) + # Since the translator is configured by the user, we can't guarantee that it will + # be able to parse the default version. So we first cast it to a tag using the default + # value and the users configured tag format, then parse it back to a version object + default_initial_version = translator.from_tag( + translator.str_to_tag(DEFAULT_VERSION) + ) + if default_initial_version is None: + # This should never happen, but if it does, it's a bug + raise InternalError( + "Translator was unable to parse the embedded default version" + ) + return default_initial_version.bump(forced_level_bump) _, latest_version = ts_and_vs[0] if forced_level_bump is not LevelBump.PRERELEASE_REVISION: diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index e9a254978..cc2a7dfc3 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -270,6 +270,7 @@ def next_version( translator.str_to_tag(DEFAULT_VERSION) ) if default_initial_version is None: + # This should never happen, but if it does, it's a bug raise InternalError( "Translator was unable to parse the embedded default version" ) diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 922160358..b0226387d 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -19,6 +19,9 @@ repo_w_trunk_only_angular_commits, repo_w_trunk_only_angular_commits_using_tag_format, ) +from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import ( + repo_w_no_tags_angular_commits_using_tag_format, +) from tests.util import ( add_text_to_file, assert_exit_code, @@ -149,52 +152,107 @@ def test_version_print_next_version( @pytest.mark.parametrize( "repo_result, commits, force_args, next_release_version", [ - ( - lazy_fixture(repo_fixture_name), - lazy_fixture(angular_minor_commits.__name__), - cli_args, - next_release_version, - ) - for repo_fixture_name in ( - repo_w_trunk_only_angular_commits.__name__, - repo_w_trunk_only_angular_commits_using_tag_format.__name__, - ) - for cli_args, next_release_version in ( - # Dynamic version bump determination (based on commits) - ([], "0.2.0"), - # Dynamic version bump determination (based on commits) with build metadata - (["--build-metadata", "build.12345"], "0.2.0+build.12345"), - # Forced version bump - (["--prerelease"], "0.1.1-rc.1"), - (["--patch"], "0.1.2"), - (["--minor"], "0.2.0"), - (["--major"], "1.0.0"), - # Forced version bump with --build-metadata - (["--patch", "--build-metadata", "build.12345"], "0.1.2+build.12345"), - # Forced version bump with --as-prerelease - (["--prerelease", "--as-prerelease"], "0.1.1-rc.1"), - (["--patch", "--as-prerelease"], "0.1.2-rc.1"), - (["--minor", "--as-prerelease"], "0.2.0-rc.1"), - (["--major", "--as-prerelease"], "1.0.0-rc.1"), - # Forced version bump with --as-prerelease and modified --prerelease-token - ( - ["--patch", "--as-prerelease", "--prerelease-token", "beta"], - "0.1.2-beta.1", - ), - # Forced version bump with --as-prerelease and modified --prerelease-token - # and --build-metadata - ( - [ - "--patch", - "--as-prerelease", - "--prerelease-token", - "beta", - "--build-metadata", - "build.12345", - ], - "0.1.2-beta.1+build.12345", - ), - ) + *[ + pytest.param( + lazy_fixture(repo_fixture_name), + lazy_fixture(angular_minor_commits.__name__), + cli_args, + next_release_version, + marks=marks if marks else [], + ) + for repo_fixture_name, marks in ( + (repo_w_trunk_only_angular_commits.__name__, None), + ( + repo_w_trunk_only_angular_commits_using_tag_format.__name__, + pytest.mark.comprehensive, + ), + ) + for cli_args, next_release_version in ( + # Dynamic version bump determination (based on commits) + ([], "0.2.0"), + # Dynamic version bump determination (based on commits) with build metadata + (["--build-metadata", "build.12345"], "0.2.0+build.12345"), + # Forced version bump + (["--prerelease"], "0.1.1-rc.1"), + (["--patch"], "0.1.2"), + (["--minor"], "0.2.0"), + (["--major"], "1.0.0"), + # Forced version bump with --build-metadata + (["--patch", "--build-metadata", "build.12345"], "0.1.2+build.12345"), + # Forced version bump with --as-prerelease + (["--prerelease", "--as-prerelease"], "0.1.1-rc.1"), + (["--patch", "--as-prerelease"], "0.1.2-rc.1"), + (["--minor", "--as-prerelease"], "0.2.0-rc.1"), + (["--major", "--as-prerelease"], "1.0.0-rc.1"), + # Forced version bump with --as-prerelease and modified --prerelease-token + ( + ["--patch", "--as-prerelease", "--prerelease-token", "beta"], + "0.1.2-beta.1", + ), + # Forced version bump with --as-prerelease and modified --prerelease-token + # and --build-metadata + ( + [ + "--patch", + "--as-prerelease", + "--prerelease-token", + "beta", + "--build-metadata", + "build.12345", + ], + "0.1.2-beta.1+build.12345", + ), + ) + ], + *[ + pytest.param( + lazy_fixture(repo_fixture_name), + [], + cli_args, + next_release_version, + marks=pytest.mark.comprehensive, + ) + for repo_fixture_name in ( + repo_w_no_tags_angular_commits.__name__, + repo_w_no_tags_angular_commits_using_tag_format.__name__, + ) + for cli_args, next_release_version in ( + # Dynamic version bump determination (based on commits) + ([], "0.1.0"), + # Dynamic version bump determination (based on commits) with build metadata + (["--build-metadata", "build.12345"], "0.1.0+build.12345"), + # Forced version bump + (["--prerelease"], "0.0.0-rc.1"), + (["--patch"], "0.0.1"), + (["--minor"], "0.1.0"), + (["--major"], "1.0.0"), + # Forced version bump with --build-metadata + (["--patch", "--build-metadata", "build.12345"], "0.0.1+build.12345"), + # Forced version bump with --as-prerelease + (["--prerelease", "--as-prerelease"], "0.0.0-rc.1"), + (["--patch", "--as-prerelease"], "0.0.1-rc.1"), + (["--minor", "--as-prerelease"], "0.1.0-rc.1"), + (["--major", "--as-prerelease"], "1.0.0-rc.1"), + # Forced version bump with --as-prerelease and modified --prerelease-token + ( + ["--patch", "--as-prerelease", "--prerelease-token", "beta"], + "0.0.1-beta.1", + ), + # Forced version bump with --as-prerelease and modified --prerelease-token + # and --build-metadata + ( + [ + "--patch", + "--as-prerelease", + "--prerelease-token", + "beta", + "--build-metadata", + "build.12345", + ], + "0.0.1-beta.1+build.12345", + ), + ) + ], ], ) def test_version_print_tag_prints_next_tag( @@ -227,10 +285,11 @@ def test_version_print_tag_prints_next_tag( tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] next_release_tag = tag_format_str.format(version=next_release_version) - # Make a commit to ensure we have something to release - # otherwise the "no release will be made" logic will kick in first - add_text_to_file(repo, file_in_repo) - repo.git.commit(m=commits[-1], a=True) + if len(commits) > 1: + # Make a commit to ensure we have something to release + # otherwise the "no release will be made" logic will kick in first + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[-1], a=True) # Setup: take measurement before running the version command repo_status_before = repo.git.status(short=True) diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py index 176d5a5d5..45c3b40dd 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py @@ -214,6 +214,50 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # --------------------------------------------------------------------------- # +@pytest.fixture +def repo_w_no_tags_angular_commits_using_tag_format( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_no_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_no_tags: str, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + """ + Replicates repo with no tags, but with a tag format X{version} + + Follows tag format defined in python-semantic-release#1137 + """ + repo_name = repo_w_no_tags_angular_commits_using_tag_format.__name__ + commit_type: CommitConvention = ( + repo_name.split("_commits", maxsplit=1)[0].split("_")[-1] # type: ignore[assignment] + ) + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_repo_w_no_tags( + commit_type=commit_type, + tag_format_str="X{version}", + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_no_tags, + build_repo_func=_build_repo, + dest_dir=example_project_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return { + "definition": cached_repo_data["build_definition"], + "repo": example_project_git_repo(), + } + + @pytest.fixture def repo_w_no_tags_angular_commits( build_trunk_only_repo_w_no_tags: BuildSpecificRepoFn, From f9a20787437d0f26074fe2121bf0a29576a96df0 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 11 Jan 2025 18:48:46 -0700 Subject: [PATCH 08/10] fix(changelog): fixes PSR release commit exclusions for customized commit messages (#1139) * fix(config-changelog): validate `changelog.exclude_commit_patterns` on config load * test(fixtures): relocate sanitize changelog functions * test(cmd-version): add test to validate that custom release messages are ignored in changelog * test(config): add `changelog.exclude_commit_patterns` validation check * style(config): refactor import names of `re` module --- src/semantic_release/cli/config.py | 47 +++- .../e2e/cmd_version/bump_version/conftest.py | 75 ------ .../git_flow/test_repo_1_channel.py | 11 +- .../git_flow/test_repo_2_channels.py | 11 +- .../git_flow/test_repo_3_channels.py | 11 +- .../git_flow/test_repo_4_channels.py | 11 +- .../github_flow/test_repo_1_channel.py | 11 +- .../github_flow/test_repo_2_channels.py | 11 +- .../trunk_based_dev/test_repo_trunk.py | 11 +- .../test_repo_trunk_dual_version_support.py | 11 +- ...runk_dual_version_support_w_prereleases.py | 11 +- .../test_repo_trunk_w_prereleases.py | 11 +- ...est_version_changelog_custom_commit_msg.py | 218 ++++++++++++++++++ tests/e2e/conftest.py | 84 +++++++ .../unit/semantic_release/cli/test_config.py | 53 +++++ 15 files changed, 433 insertions(+), 154 deletions(-) create mode 100644 tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py 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", [ From 0418fd8d27aac14925aafa50912e751e3aeff2f7 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 11 Jan 2025 20:38:17 -0700 Subject: [PATCH 09/10] feat(config): expand dynamic parser import to handle a filepath to module (#1135) * test(fixtures): remove import checking/loading of custom parser in `use_custom_parser` * test(config): extend import parser unit tests to evaluate file paths to modules * docs(commit-parsing): add the new custom parser import spec description for direct path imports Resolves: #687 * docs(configuration): adjust `commit_parser` option definition for direct path imports --- docs/commit_parsing.rst | 47 ++++++++++++++----- docs/configuration.rst | 2 +- src/semantic_release/helpers.py | 45 ++++++++++++++++-- tests/fixtures/example_project.py | 14 +----- .../unit/semantic_release/cli/test_config.py | 39 ++++++++++++--- 5 files changed, 111 insertions(+), 36 deletions(-) diff --git a/docs/commit_parsing.rst b/docs/commit_parsing.rst index 7c804e695..fe1d3f376 100644 --- a/docs/commit_parsing.rst +++ b/docs/commit_parsing.rst @@ -334,19 +334,40 @@ where appropriate to assist with static type-checking. The :ref:`commit_parser ` option, if set to a string which does not match one of Python Semantic Release's built-in commit parsers, will be -used to attempt to dynamically import a custom commit parser class. As such you will -need to ensure that your custom commit parser is import-able from the environment in -which you are running Python Semantic Release. The string should be structured in the -standard ``module:attr`` format; for example, to import the class ``MyCommitParser`` -from the file ``custom_parser.py`` at the root of your repository, you should specify -``"commit_parser=custom_parser:MyCommitParser"`` in your configuration, and run the -``semantic-release`` command line interface from the root of your repository. Equally -you can ensure that the module containing your parser class is installed in the same -virtual environment as semantic-release. If you can run -``python -c "from $MODULE import $CLASS"`` successfully, specifying -``commit_parser="$MODULE:$CLASS"`` is sufficient. You may need to set the -``PYTHONPATH`` environment variable to the directory containing the module with -your commit parser. +used to attempt to dynamically import a custom commit parser class. + +In order to use your custom parser, you must provide how to import the module and class +via the configuration option. There are two ways to provide the import string: + +1. **File Path & Class**: The format is ``"path/to/module_file.py:ClassName"``. This + is the easiest way to provide a custom parser. This method allows you to store your + custom parser directly in the repository with no additional installation steps. PSR + will locate the file, load the module, and instantiate the class. Relative paths are + recommended and it should be provided relative to the current working directory. This + import variant is available in v9.16.0 and later. + +2. **Module Path & Class**: The format is ``"package.module_name:ClassName"``. This + method allows you to store your custom parser in a package that is installed in the + same environment as PSR. This method is useful if you want to share your custom parser + across multiple repositories. To share it across multiple repositories generally you will + need to publish the parser as its own separate package and then ``pip install`` it into + the current virtual environment. You can also keep it in the same repository as your + project as long as it is in the current directory of the virtual environment and is + locatable by the Python import system. You may need to set the ``PYTHONPATH`` environment + variable if you have a more complex directory structure. This import variant is available + in v8.0.0 and later. + + To test that your custom parser is importable, you can run the following command in the + directory where PSR will be executed: + + .. code-block:: bash + + python -c "from package.module_name import ClassName" + + .. note:: + Remember this is basic python import rules so the package name is optional and generally + packages are defined by a directory with ``__init__.py`` files. + .. _commit_parser-tokens: diff --git a/docs/configuration.rst b/docs/configuration.rst index a3a587045..fac883479 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -796,7 +796,7 @@ Built-in parsers: * ``tag`` - :ref:`TagCommitParser ` *(deprecated in v9.12.0)* You can set any of the built-in parsers by their keyword but you can also specify -your own commit parser in ``module:attr`` form. +your own commit parser in ``path/to/module_file.py:Class`` or ``module:Class`` form. For more information see :ref:`commit-parsing`. diff --git a/src/semantic_release/helpers.py b/src/semantic_release/helpers.py index 3725ddfa5..db82a97eb 100644 --- a/src/semantic_release/helpers.py +++ b/src/semantic_release/helpers.py @@ -1,9 +1,11 @@ -import importlib +import importlib.util import logging +import os import re import string +import sys from functools import lru_cache, wraps -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any, Callable, NamedTuple, TypeVar from urllib.parse import urlsplit @@ -69,8 +71,43 @@ def dynamic_import(import_path: str) -> Any: """ log.debug("Trying to import %s", import_path) module_name, attr = import_path.split(":", maxsplit=1) - module = importlib.import_module(module_name) - return getattr(module, attr) + + # Check if the module is a file path, if it can be resolved and exists on disk then import as a file + module_filepath = Path(module_name).resolve() + if module_filepath.exists(): + module_path = ( + module_filepath.stem + if Path(module_name).is_absolute() + else str(Path(module_name).with_suffix("")).replace(os.sep, ".") + ) + + if module_path not in sys.modules: + spec = importlib.util.spec_from_file_location( + module_path, str(module_filepath) + ) + if spec is None: + raise ImportError(f"Could not import {module_filepath}") + + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + sys.modules.update({spec.name: module}) + spec.loader.exec_module(module) # type: ignore[union-attr] + + return getattr(sys.modules[module_path], attr) + + # Otherwise, import as a module + try: + module = importlib.import_module(module_name) + return getattr(module, attr) + except TypeError as err: + raise ImportError( + str.join( + "\n", + [ + str(err.args[0]), + "Verify the import format matches 'module:attribute' or 'path/to/module:attribute'", + ], + ) + ) from err class ParsedGitUrl(NamedTuple): diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 83d0b6171..2d9783cdf 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from importlib import import_module from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING, Generator @@ -60,7 +59,7 @@ class UpdatePyprojectTomlFn(Protocol): def __call__(self, setting: str, value: Any) -> None: ... class UseCustomParserFn(Protocol): - def __call__(self, module_import_str: str) -> type[CommitParser]: ... + def __call__(self, module_import_str: str) -> None: ... class UseHvcsFn(Protocol): def __call__(self, domain: str | None = None) -> type[HvcsBase]: ... @@ -497,17 +496,8 @@ def use_custom_parser( ) -> UseCustomParserFn: """Modify the configuration file to use a user defined string parser.""" - def _use_custom_parser(module_import_str: str) -> type[CommitParser]: - # validate this is importable before writing to parser - module_name, attr = module_import_str.split(":", maxsplit=1) - try: - module = import_module(module_name) - custom_class = getattr(module, attr) - except (ModuleNotFoundError, AttributeError) as err: - raise ValueError("Custom parser object not found!") from err - + def _use_custom_parser(module_import_str: str) -> None: update_pyproject_toml(pyproject_toml_config_option_parser, module_import_str) - return custom_class return _use_custom_parser diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index d76456e7f..d083d01d1 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -1,6 +1,9 @@ from __future__ import annotations import os +import shutil +import sys +from pathlib import Path from re import compile as regexp from typing import TYPE_CHECKING from unittest import mock @@ -37,7 +40,6 @@ ) if TYPE_CHECKING: - from pathlib import Path from typing import Any from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn @@ -226,8 +228,12 @@ def test_load_valid_runtime_config( @pytest.mark.parametrize( "commit_parser", [ + # Module:Class string f"{CustomParserWithNoOpts.__module__}:{CustomParserWithNoOpts.__name__}", f"{CustomParserWithOpts.__module__}:{CustomParserWithOpts.__name__}", + # File path module:Class string + f"{CustomParserWithNoOpts.__module__.replace('.', '/')}.py:{CustomParserWithNoOpts.__name__}", + f"{CustomParserWithOpts.__module__.replace('.', '/')}.py:{CustomParserWithOpts.__name__}", ], ) def test_load_valid_runtime_config_w_custom_parser( @@ -236,17 +242,32 @@ def test_load_valid_runtime_config_w_custom_parser( example_project_dir: ExProjectDir, example_pyproject_toml: Path, change_to_ex_proj_dir: None, + request: pytest.FixtureRequest, ): + fake_sys_modules = {**sys.modules} + + if ".py" in commit_parser: + module_filepath = Path(commit_parser.split(":")[0]) + module_filepath.parent.mkdir(parents=True, exist_ok=True) + module_filepath.parent.joinpath("__init__.py").touch() + shutil.copy( + src=str(request.config.rootpath / module_filepath), + dst=str(module_filepath), + ) + fake_sys_modules.pop( + str(Path(module_filepath).with_suffix("")).replace(os.sep, ".") + ) + build_configured_base_repo( example_project_dir, commit_type=commit_parser, ) - runtime_ctx = RuntimeContext.from_raw_config( - RawConfig.model_validate(load_raw_config_file(example_pyproject_toml)), - global_cli_options=GlobalCommandLineOptions(), - ) - assert runtime_ctx + with mock.patch.dict(sys.modules, fake_sys_modules, clear=True): + assert RuntimeContext.from_raw_config( + RawConfig.model_validate(load_raw_config_file(example_pyproject_toml)), + global_cli_options=GlobalCommandLineOptions(), + ) @pytest.mark.parametrize( @@ -258,6 +279,12 @@ def test_load_valid_runtime_config_w_custom_parser( f"{CustomParserWithOpts.__module__}:MissingCustomParser", # Incomplete class implementation f"{IncompleteCustomParser.__module__}:{IncompleteCustomParser.__name__}", + # Non-existant module file + "tests/missing_module.py:CustomParser", + # Non-existant class in module file + f"{CustomParserWithOpts.__module__.replace('.', '/')}.py:MissingCustomParser", + # Incomplete class implementation in module file + f"{IncompleteCustomParser.__module__.replace('.', '/')}.py:{IncompleteCustomParser.__name__}", ], ) def test_load_invalid_custom_parser( From de838f32ea4bed4d0c44d73a1307f9874011c041 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 12 Jan 2025 03:51:43 +0000 Subject: [PATCH 10/10] 9.16.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/semantic_release/__init__.py | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d3c932c..38439a798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,67 @@ # CHANGELOG +## v9.16.0 (2025-01-12) + +### Bug Fixes + +- **changelog**: Fixes PSR release commit exclusions for customized commit messages + ([#1139](https://github.com/python-semantic-release/python-semantic-release/pull/1139), + [`f9a2078`](https://github.com/python-semantic-release/python-semantic-release/commit/f9a20787437d0f26074fe2121bf0a29576a96df0)) + +* fix(config-changelog): validate `changelog.exclude_commit_patterns` on config load + +* test(fixtures): relocate sanitize changelog functions + +* test(cmd-version): add test to validate that custom release messages are ignored in changelog + +* test(config): add `changelog.exclude_commit_patterns` validation check + +* style(config): refactor import names of `re` module + +- **cmd-version**: Fix `--print-tag` result to match configured tag format + ([#1134](https://github.com/python-semantic-release/python-semantic-release/pull/1134), + [`a990aa7`](https://github.com/python-semantic-release/python-semantic-release/commit/a990aa7ab0a9d52d295c04d54d20e9c9f2db2ca5)) + +* test(fixtures): add new trunk repo that has a different tag format + +* test(fixtures): add helper to extract config settings from repo action definition + +* test(cmd-version): expand testing of `--print-tag` & `--print-last-released-tag` + +PSR did not have enough testing to demonstrate testing of the tag generation when the tag format was + configured differently than normal. This commit adds a significant portion of testing to exercise + the print tag functionality which must match the configured tag format. + +- **cmd-version**: Fix tag format on default version when force bump for initial release + ([#1138](https://github.com/python-semantic-release/python-semantic-release/pull/1138), + [`007fd00`](https://github.com/python-semantic-release/python-semantic-release/commit/007fd00a3945ed211ece4baab0b79ad93dc018f5)) + +Resolves: #1137 + +* test(fixtures): add new unreleased trunk repo with a different tag format + +* test(cmd-version): ensure forced bump version on initial release follows tag format + +ref: #1137 + +### Features + +- **config**: Expand dynamic parser import to handle a filepath to module + ([#1135](https://github.com/python-semantic-release/python-semantic-release/pull/1135), + [`0418fd8`](https://github.com/python-semantic-release/python-semantic-release/commit/0418fd8d27aac14925aafa50912e751e3aeff2f7)) + +* test(fixtures): remove import checking/loading of custom parser in `use_custom_parser` + +* test(config): extend import parser unit tests to evaluate file paths to modules + +* docs(commit-parsing): add the new custom parser import spec description for direct path imports + +Resolves: #687 + +* docs(configuration): adjust `commit_parser` option definition for direct path imports + + ## v9.15.2 (2024-12-16) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 107a8bfc6..91b9d3692 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "9.15.2" +version = "9.16.0" description = "Automatic Semantic Versioning for Python projects" requires-python = ">=3.8" license = { text = "MIT" } diff --git a/src/semantic_release/__init__.py b/src/semantic_release/__init__.py index 5e8b3de74..d5a2e8cf6 100644 --- a/src/semantic_release/__init__.py +++ b/src/semantic_release/__init__.py @@ -24,7 +24,7 @@ tags_and_versions, ) -__version__ = "9.15.2" +__version__ = "9.16.0" __all__ = [ "CommitParser",