From eccdb59ccc2a14e4a2c8bb76b33ea6031f70ecae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 00:45:21 -0700 Subject: [PATCH 1/7] ci(deps): bump `python-semantic-release/publish-action@v9.19.0` to 9.19.1 (#1184) --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 016c553a4..d38b53f6c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -138,7 +138,7 @@ jobs: build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.19.0 + uses: python-semantic-release/publish-action@v9.19.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} From a900b2b21318a8a59cdb25c3d99de732340b77bb Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 16:23:17 -0700 Subject: [PATCH 2/7] ci(tests-e2e): mark long running tests to prevent windows execution --- .../git_flow/test_repo_1_channel.py | 14 +- .../git_flow/test_repo_2_channels.py | 14 +- .../git_flow/test_repo_3_channels.py | 16 +- .../git_flow/test_repo_4_channels.py | 14 +- .../github_flow/test_repo_1_channel.py | 14 +- .../github_flow/test_repo_2_channels.py | 14 +- .../test_repo_trunk_dual_version_support.py | 14 +- ...runk_dual_version_support_w_prereleases.py | 14 +- .../test_repo_trunk_w_prereleases.py | 14 +- tests/e2e/cmd_version/test_version_bump.py | 1218 ++++++++--------- 10 files changed, 640 insertions(+), 706 deletions(-) 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 74cd8c7e4..e12ca31e6 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_git_flow_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_git_flow_emoji_commits.__name__, - repo_w_git_flow_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_git_flow_conventional_commits.__name__, + repo_w_git_flow_emoji_commits.__name__, + repo_w_git_flow_scipy_commits.__name__, + ] ], ) def test_gitflow_repo_rebuild_1_channel( 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 31e041770..035f679bd 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, - repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, + ] ], ) def test_gitflow_repo_rebuild_2_channels( 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 c3ee44cac..825b7f7c3 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 @@ -46,15 +46,13 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits_using_tag_format.__name__, - repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, - repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits_using_tag_format.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, + ] ], ) def test_gitflow_repo_rebuild_3_channels( 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 3e2b6c14c..e1cadb5a6 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_git_flow_w_beta_alpha_rev_prereleases_n_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits.__name__, - repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_conventional_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits.__name__, + repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits.__name__, + ] ], ) def test_gitflow_repo_rebuild_4_channels( 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 0ce3dae7c..e836716d6 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_github_flow_w_default_release_channel_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_github_flow_w_default_release_channel_emoji_commits.__name__, - repo_w_github_flow_w_default_release_channel_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_github_flow_w_default_release_channel_conventional_commits.__name__, + repo_w_github_flow_w_default_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_default_release_channel_scipy_commits.__name__, + ] ], ) def test_githubflow_repo_rebuild_1_channel( 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 bf197bc3d..03054ac6a 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_github_flow_w_feature_release_channel_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, - repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_github_flow_w_feature_release_channel_conventional_commits.__name__, + repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, + ] ], ) def test_githubflow_repo_rebuild_2_channels( 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 ecc4e3990..6c15f2bd8 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 @@ -46,14 +46,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_trunk_only_dual_version_spt_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_trunk_only_dual_version_spt_emoji_commits.__name__, - repo_w_trunk_only_dual_version_spt_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_trunk_only_dual_version_spt_conventional_commits.__name__, + repo_w_trunk_only_dual_version_spt_emoji_commits.__name__, + repo_w_trunk_only_dual_version_spt_scipy_commits.__name__, + ] ], ) def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( 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 720bdfc5c..74d5f361f 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 @@ -46,14 +46,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_trunk_only_dual_version_spt_w_prereleases_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits.__name__, - repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_trunk_only_dual_version_spt_w_prereleases_conventional_commits.__name__, + repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits.__name__, + repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits.__name__, + ] ], ) def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( 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 958cac4e9..9d1171e59 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 @@ -45,14 +45,12 @@ @pytest.mark.parametrize( "repo_fixture_name", [ - repo_w_trunk_only_n_prereleases_conventional_commits.__name__, - *[ - pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) - for repo_fixture_name in [ - repo_w_trunk_only_n_prereleases_emoji_commits.__name__, - repo_w_trunk_only_n_prereleases_scipy_commits.__name__, - ] - ], + pytest.param(repo_fixture_name, marks=pytest.mark.comprehensive) + for repo_fixture_name in [ + repo_w_trunk_only_n_prereleases_conventional_commits.__name__, + repo_w_trunk_only_n_prereleases_emoji_commits.__name__, + repo_w_trunk_only_n_prereleases_scipy_commits.__name__, + ] ], ) def test_trunk_repo_rebuild_w_prereleases( diff --git a/tests/e2e/cmd_version/test_version_bump.py b/tests/e2e/cmd_version/test_version_bump.py index 6efb10a96..245c05505 100644 --- a/tests/e2e/cmd_version/test_version_bump.py +++ b/tests/e2e/cmd_version/test_version_bump.py @@ -725,93 +725,85 @@ def test_version_next_greater_than_version_one_no_bump_conventional( ), xdist_sort_hack( [ - ( - # Default case should be a minor bump since last full release was 1.1.1 - # last tag is a prerelease 1.2.0-rc.2 - lazy_fixture( - repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__ - ), - lazy_fixture(emoji_minor_commits.__name__), - False, - "alpha", - "1.2.0", - "main", - ), - *[ - pytest.param( - lazy_fixture(repo_fixture_name), - [] if commit_messages is None else lazy_fixture(commit_messages), - prerelease, - prerelease_token, - expected_new_version, - "main" if branch_name is None else branch_name, - marks=pytest.mark.comprehensive, - ) - for (repo_fixture_name, prerelease_token), values in { - # Latest version for repo_with_git_flow is currently 1.2.0-alpha.2 - # The last full release version was 1.1.1, so it's had a minor - # prerelease + pytest.param( + lazy_fixture(repo_fixture_name), + [] if commit_messages is None else lazy_fixture(commit_messages), + prerelease, + prerelease_token, + expected_new_version, + "main" if branch_name is None else branch_name, + marks=pytest.mark.comprehensive, + ) + for (repo_fixture_name, prerelease_token), values in { + # Latest version for repo_with_git_flow is currently 1.2.0-alpha.2 + # The last full release version was 1.1.1, so it's had a minor + # prerelease + ( + repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, + "alpha", + ): [ + (emoji_patch_commits.__name__, False, "1.1.2", None), ( - repo_w_git_flow_w_alpha_prereleases_n_emoji_commits.__name__, - "alpha", - ): [ - (emoji_patch_commits.__name__, False, "1.1.2", None), - ( - emoji_patch_commits.__name__, - True, - "1.1.2-alpha.1", - None, - ), - ( - emoji_minor_commits.__name__, - True, - "1.2.0-alpha.3", - "feat/feature-4", # branch - ), - (emoji_major_commits.__name__, False, "2.0.0", None), - ( - emoji_major_commits.__name__, - True, - "2.0.0-alpha.1", - None, - ), - ], - # Latest version for repo_with_git_flow_and_release_channels is - # currently 1.1.0 + emoji_patch_commits.__name__, + True, + "1.1.2-alpha.1", + None, + ), ( - repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, - "alpha", - ): [ - (emoji_patch_commits.__name__, False, "1.1.1", None), - ( - emoji_patch_commits.__name__, - True, - "1.1.1-alpha.1", - None, - ), - (emoji_minor_commits.__name__, False, "1.2.0", None), - ( - emoji_minor_commits.__name__, - True, - "1.2.0-alpha.1", - None, - ), - (emoji_major_commits.__name__, False, "2.0.0", None), - ( - emoji_major_commits.__name__, - True, - "2.0.0-alpha.1", - None, - ), - ], - }.items() - for ( - commit_messages, - prerelease, - expected_new_version, - branch_name, - ) in values # type: ignore[attr-defined] - ], + emoji_minor_commits.__name__, + False, + "1.2.0", + None, + ), + ( + emoji_minor_commits.__name__, + True, + "1.2.0-alpha.3", + "feat/feature-4", # branch + ), + (emoji_major_commits.__name__, False, "2.0.0", None), + ( + emoji_major_commits.__name__, + True, + "2.0.0-alpha.1", + None, + ), + ], + # Latest version for repo_with_git_flow_and_release_channels is + # currently 1.1.0 + ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits.__name__, + "alpha", + ): [ + (emoji_patch_commits.__name__, False, "1.1.1", None), + ( + emoji_patch_commits.__name__, + True, + "1.1.1-alpha.1", + None, + ), + (emoji_minor_commits.__name__, False, "1.2.0", None), + ( + emoji_minor_commits.__name__, + True, + "1.2.0-alpha.1", + None, + ), + (emoji_major_commits.__name__, False, "2.0.0", None), + ( + emoji_major_commits.__name__, + True, + "2.0.0-alpha.1", + None, + ), + ], + }.items() + for ( + commit_messages, + prerelease, + expected_new_version, + branch_name, + ) in values # type: ignore[attr-defined] ] ), ) @@ -1032,94 +1024,86 @@ def test_version_next_greater_than_version_one_no_bump_emoji( ), xdist_sort_hack( [ - ( - # Default case should be a minor bump since last full release was 1.1.1 - # last tag is a prerelease 1.2.0-rc.2 - lazy_fixture( - repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__ - ), - lazy_fixture(scipy_minor_commits.__name__), - False, - "alpha", - "1.2.0", - "main", - ), - *[ - pytest.param( - lazy_fixture(repo_fixture_name), - [] if commit_messages is None else lazy_fixture(commit_messages), - prerelease, - prerelease_token, - expected_new_version, - "main" if branch_name is None else branch_name, - marks=pytest.mark.comprehensive, - ) - for (repo_fixture_name, prerelease_token), values in { - # Latest version for repo_with_git_flow is currently 1.2.0-alpha.2 - # The last full release version was 1.1.1, so it's had a minor - # prerelease + pytest.param( + lazy_fixture(repo_fixture_name), + [] if commit_messages is None else lazy_fixture(commit_messages), + prerelease, + prerelease_token, + expected_new_version, + "main" if branch_name is None else branch_name, + marks=pytest.mark.comprehensive, + ) + for (repo_fixture_name, prerelease_token), values in { + # Latest version for repo_with_git_flow is currently 1.2.0-alpha.2 + # The last full release version was 1.1.1, so it's had a minor + # prerelease + ( + repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, + "alpha", + ): [ + (scipy_patch_commits.__name__, False, "1.1.2", None), ( - repo_w_git_flow_w_alpha_prereleases_n_scipy_commits.__name__, - "alpha", - ): [ - (scipy_patch_commits.__name__, False, "1.1.2", None), - ( - scipy_patch_commits.__name__, - True, - "1.1.2-alpha.1", - None, - ), - ( - scipy_minor_commits.__name__, - True, - "1.2.0-alpha.3", - "feat/feature-4", # branch - ), - (scipy_major_commits.__name__, False, "2.0.0", None), - ( - scipy_major_commits.__name__, - True, - "2.0.0-alpha.1", - None, - ), - ], - # Latest version for repo_with_git_flow_and_release_channels is - # currently 1.1.0 + scipy_patch_commits.__name__, + True, + "1.1.2-alpha.1", + None, + ), ( - repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, - "alpha", - ): [ - (scipy_patch_commits.__name__, False, "1.1.1", None), - ( - scipy_patch_commits.__name__, - True, - "1.1.1-alpha.1", - None, - ), - (scipy_minor_commits.__name__, False, "1.2.0", None), - ( - scipy_minor_commits.__name__, - True, - "1.2.0-alpha.1", - None, - ), - (scipy_major_commits.__name__, False, "2.0.0", None), - ( - scipy_major_commits.__name__, - True, - "2.0.0-alpha.1", - None, - ), - ], - }.items() - for ( - commit_messages, - prerelease, - expected_new_version, - branch_name, - ) in values # type: ignore[attr-defined] - ], - ] + scipy_minor_commits.__name__, + False, + "1.2.0", + None, + ), + ( + scipy_minor_commits.__name__, + True, + "1.2.0-alpha.3", + "feat/feature-4", # branch + ), + (scipy_major_commits.__name__, False, "2.0.0", None), + ( + scipy_major_commits.__name__, + True, + "2.0.0-alpha.1", + None, + ), + ], + # Latest version for repo_with_git_flow_and_release_channels is + # currently 1.1.0 + ( + repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits.__name__, + "alpha", + ): [ + (scipy_patch_commits.__name__, False, "1.1.1", None), + ( + scipy_patch_commits.__name__, + True, + "1.1.1-alpha.1", + None, + ), + (scipy_minor_commits.__name__, False, "1.2.0", None), + ( + scipy_minor_commits.__name__, + True, + "1.2.0-alpha.1", + None, + ), + (scipy_major_commits.__name__, False, "2.0.0", None), + ( + scipy_major_commits.__name__, + True, + "2.0.0-alpha.1", + None, + ), + ], + }.items() + for ( + commit_messages, + prerelease, + expected_new_version, + branch_name, + ) in values # type: ignore[attr-defined] + ], ), ) def test_version_next_greater_than_version_one_scipy( @@ -1842,256 +1826,240 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( ), xdist_sort_hack( [ - ( + pytest.param( + lazy_fixture(repo_fixture_name), + commit_messages, + prerelease, + "rc" if prerelease_token is None else prerelease_token, + major_on_zero, + allow_zero_version, + next_release_version, + "main" if branch_name is None else branch_name, + marks=pytest.mark.comprehensive, + ) + for (repo_fixture_name, prerelease_token), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 - # Given the major_on_zero is False and the version is starting at 0.0.0, - # the major level commits are limited to only causing a minor level bump - lazy_fixture(repo_w_no_tags_emoji_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - False, - "rc", - False, - True, - "0.1.0", - "main", - ), - *[ - pytest.param( - lazy_fixture(repo_fixture_name), - commit_messages, - prerelease, - "rc" if prerelease_token is None else prerelease_token, - major_on_zero, - allow_zero_version, - next_release_version, - "main" if branch_name is None else branch_name, - marks=pytest.mark.comprehensive, - ) - for (repo_fixture_name, prerelease_token), values in { - # Latest version for repo_with_no_tags is currently 0.0.0 (default) - # It's biggest change type is minor, so the next version should be 0.1.0 + ( + repo_w_no_tags_emoji_commits.__name__, + None, + ): [ + *( + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0", None) + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(emoji_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value ( - repo_w_no_tags_emoji_commits.__name__, + lazy_fixture(emoji_major_commits.__name__), + False, + False, + True, + "0.1.0", None, - ): [ - *( - # when prerelease is False, & major_on_zero is False & - # allow_zero_version is True, the version should be - # 0.1.0, with the given commits - (commits, False, False, True, "0.1.0", None) - for commits in ( - # Even when this test does not change anything, the base modification - # will be a minor change and thus the version will be bumped to 0.1.0 - None, - # Non version bumping commits are absorbed into the previously detected minor bump - lazy_fixture(emoji_chore_commits.__name__), - # Patch commits are absorbed into the previously detected minor bump - lazy_fixture(emoji_patch_commits.__name__), - # Minor level commits are absorbed into the previously detected minor bump - lazy_fixture(emoji_minor_commits.__name__), - # Given the major_on_zero is False and the version is starting at 0.0.0, - # the major level commits are limited to only causing a minor level bump - # lazy_fixture(emoji_major_commits.__name__), # used as default - ) - ), - # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, - # the version should only be minor bumped when provided major commits because - # of the major_on_zero value - ( - lazy_fixture(emoji_major_commits.__name__), - False, - False, - True, - "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - # when prerelease is False, & major_on_zero is True & allow_zero_version is True, - # the version should be major bumped when provided major commits because - # of the major_on_zero value - ( + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), lazy_fixture(emoji_major_commits.__name__), + ) + ), + ], + # Latest version for repo_with_single_branch is currently 0.1.1 + # Note repo_with_single_branch isn't modelled with prereleases + ( + repo_w_trunk_only_emoji_commits.__name__, + None, + ): [ + *( + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value + ( + lazy_fixture(emoji_patch_commits.__name__), False, + major_on_zero, True, - True, - "1.0.0", + "0.1.2", None, - ), - *( - # when prerelease is False, & allow_zero_version is False, the version should be - # 1.0.0, across the board because 0 is not a valid major version. - # major_on_zero is ignored as it is not relevant but tested for completeness - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(emoji_chore_commits.__name__), - lazy_fixture(emoji_patch_commits.__name__), - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - ], - # Latest version for repo_with_single_branch is currently 0.1.1 - # Note repo_with_single_branch isn't modelled with prereleases - ( - repo_w_trunk_only_emoji_commits.__name__, - None, - ): [ - *( - # when prerelease must be False, and allow_zero_version is True, - # the version is patch bumped because of the patch level commits - # regardless of the major_on_zero value - ( - lazy_fixture(emoji_patch_commits.__name__), - False, - major_on_zero, - True, - "0.1.2", - None, - ) - for major_on_zero in (True, False) - ), - *( - # when prerelease must be False, and allow_zero_version is True, - # the version is minor bumped because of the major_on_zero value=False - (commits, False, False, True, "0.2.0", None) - for commits in ( - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), + ) + for major_on_zero in (True, False) + ), + *( # when prerelease must be False, and allow_zero_version is True, - # but the major_on_zero is True, then when a major level commit is given, - # the version should be bumped to the next major version - ( + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0", None) + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), lazy_fixture(emoji_major_commits.__name__), - False, - True, - True, - "1.0.0", + ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - *( - # when prerelease must be False, & allow_zero_version is False, the version should be - # 1.0.0, with any change regardless of major_on_zero - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(emoji_chore_commits.__name__), - lazy_fixture(emoji_patch_commits.__name__), - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - ], - # Latest version for repo_with_single_branch_and_prereleases is - # currently 0.2.0 + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) + ), + ], + # Latest version for repo_with_single_branch_and_prereleases is + # currently 0.2.0 + ( + repo_w_trunk_only_n_prereleases_emoji_commits.__name__, + None, + ): [ + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits ( - repo_w_trunk_only_n_prereleases_emoji_commits.__name__, + lazy_fixture(emoji_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", None, - ): [ + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(emoji_patch_commits.__name__), + False, + False, + True, + "0.2.1", + None, + ), + *( # when allow_zero_version is True, - # prerelease is False, & major_on_zero is False, the version should be - # patch bumped as a prerelease version, when given patch level commits - ( - lazy_fixture(emoji_patch_commits.__name__), - True, - False, - True, - "0.2.1-rc.1", + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1", None) + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) + ), + *( + # when allow_zero_version is True, prerelease is True, & major_on_zero + # is False, the version should be minor bumped, when given commits of a + # minor or major level because major_on_zero = False + (commits, False, False, True, "0.3.0", None) + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) + ), + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(emoji_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + None, + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - # when allow_zero_version is True, - # prerelease is False, & major_on_zero is False, the version should be - # patch bumped, when given patch level commits - ( + lazy_fixture(emoji_chore_commits.__name__), lazy_fixture(emoji_patch_commits.__name__), - False, - False, - True, - "0.2.1", - None, - ), - *( - # when allow_zero_version is True, - # prerelease is True, & major_on_zero is False, the version should be - # minor bumped as a prerelease version, when given commits of a minor or major level - (commits, True, False, True, "0.3.0-rc.1", None) - for commits in ( - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - *( - # when allow_zero_version is True, prerelease is True, & major_on_zero - # is False, the version should be minor bumped, when given commits of a - # minor or major level because major_on_zero = False - (commits, False, False, True, "0.3.0", None) - for commits in ( - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - # when prerelease is True, & major_on_zero is True, and allow_zero_version - # is True, the version should be bumped to 1.0.0 as a prerelease version, when - # given major level commits - ( + lazy_fixture(emoji_minor_commits.__name__), lazy_fixture(emoji_major_commits.__name__), - True, - True, - True, - "1.0.0-rc.1", - None, - ), - # when prerelease is False, & major_on_zero is True, and allow_zero_version - # is True, the version should be bumped to 1.0.0, when given major level commits - ( + ) + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), lazy_fixture(emoji_major_commits.__name__), - False, - True, - True, - "1.0.0", - None, - ), - *( - # when prerelease is True, & allow_zero_version is False, the version should be - # bumped to 1.0.0 as a prerelease version, when given any/none commits - # because 0.x is no longer a valid version regardless of the major_on_zero value - (commits, True, major_on_zero, False, "1.0.0-rc.1", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(emoji_chore_commits.__name__), - lazy_fixture(emoji_patch_commits.__name__), - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - *( - # when prerelease is True, & allow_zero_version is False, the version should be - # bumped to 1.0.0, when given any/none commits - # because 0.x is no longer a valid version regardless of the major_on_zero value - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - lazy_fixture(emoji_patch_commits.__name__), - lazy_fixture(emoji_minor_commits.__name__), - lazy_fixture(emoji_major_commits.__name__), - ) - ), - ], - }.items() - for ( - commit_messages, - prerelease, - major_on_zero, - allow_zero_version, - next_release_version, - branch_name, - ) in values # type: ignore[attr-defined] - ], + ) + ), + ], + }.items() + for ( + commit_messages, + prerelease, + major_on_zero, + allow_zero_version, + next_release_version, + branch_name, + ) in values # type: ignore[attr-defined] ], ), ) @@ -2338,256 +2306,240 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( ), xdist_sort_hack( [ - ( + pytest.param( + lazy_fixture(repo_fixture_name), + commit_messages, + prerelease, + "rc" if prerelease_token is None else prerelease_token, + major_on_zero, + allow_zero_version, + next_release_version, + "main" if branch_name is None else branch_name, + marks=pytest.mark.comprehensive, + ) + for (repo_fixture_name, prerelease_token), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 - # Given the major_on_zero is False and the version is starting at 0.0.0, - # the major level commits are limited to only causing a minor level bump - lazy_fixture(repo_w_no_tags_scipy_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - False, - "rc", - False, - True, - "0.1.0", - "main", - ), - *[ - pytest.param( - lazy_fixture(repo_fixture_name), - commit_messages, - prerelease, - "rc" if prerelease_token is None else prerelease_token, - major_on_zero, - allow_zero_version, - next_release_version, - "main" if branch_name is None else branch_name, - marks=pytest.mark.comprehensive, - ) - for (repo_fixture_name, prerelease_token), values in { - # Latest version for repo_with_no_tags is currently 0.0.0 (default) - # It's biggest change type is minor, so the next version should be 0.1.0 + ( + repo_w_no_tags_scipy_commits.__name__, + None, + ): [ + *( + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0", None) + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(scipy_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value ( - repo_w_no_tags_scipy_commits.__name__, + lazy_fixture(scipy_major_commits.__name__), + False, + False, + True, + "0.1.0", None, - ): [ - *( - # when prerelease is False, & major_on_zero is False & - # allow_zero_version is True, the version should be - # 0.1.0, with the given commits - (commits, False, False, True, "0.1.0", None) - for commits in ( - # Even when this test does not change anything, the base modification - # will be a minor change and thus the version will be bumped to 0.1.0 - None, - # Non version bumping commits are absorbed into the previously detected minor bump - lazy_fixture(scipy_chore_commits.__name__), - # Patch commits are absorbed into the previously detected minor bump - lazy_fixture(scipy_patch_commits.__name__), - # Minor level commits are absorbed into the previously detected minor bump - lazy_fixture(scipy_minor_commits.__name__), - # Given the major_on_zero is False and the version is starting at 0.0.0, - # the major level commits are limited to only causing a minor level bump - # lazy_fixture(scipy_major_commits.__name__), # used as default - ) - ), - # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, - # the version should only be minor bumped when provided major commits because - # of the major_on_zero value - ( - lazy_fixture(scipy_major_commits.__name__), - False, - False, - True, - "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - # when prerelease is False, & major_on_zero is True & allow_zero_version is True, - # the version should be major bumped when provided major commits because - # of the major_on_zero value - ( + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), lazy_fixture(scipy_major_commits.__name__), + ) + ), + ], + # Latest version for repo_with_single_branch is currently 0.1.1 + # Note repo_with_single_branch isn't modelled with prereleases + ( + repo_w_trunk_only_scipy_commits.__name__, + None, + ): [ + *( + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value + ( + lazy_fixture(scipy_patch_commits.__name__), False, + major_on_zero, True, - True, - "1.0.0", + "0.1.2", None, - ), - *( - # when prerelease is False, & allow_zero_version is False, the version should be - # 1.0.0, across the board because 0 is not a valid major version. - # major_on_zero is ignored as it is not relevant but tested for completeness - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(scipy_chore_commits.__name__), - lazy_fixture(scipy_patch_commits.__name__), - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - ], - # Latest version for repo_with_single_branch is currently 0.1.1 - # Note repo_with_single_branch isn't modelled with prereleases - ( - repo_w_trunk_only_scipy_commits.__name__, - None, - ): [ - *( - # when prerelease must be False, and allow_zero_version is True, - # the version is patch bumped because of the patch level commits - # regardless of the major_on_zero value - ( - lazy_fixture(scipy_patch_commits.__name__), - False, - major_on_zero, - True, - "0.1.2", - None, - ) - for major_on_zero in (True, False) - ), - *( - # when prerelease must be False, and allow_zero_version is True, - # the version is minor bumped because of the major_on_zero value=False - (commits, False, False, True, "0.2.0", None) - for commits in ( - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), + ) + for major_on_zero in (True, False) + ), + *( # when prerelease must be False, and allow_zero_version is True, - # but the major_on_zero is True, then when a major level commit is given, - # the version should be bumped to the next major version - ( + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0", None) + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), lazy_fixture(scipy_major_commits.__name__), - False, - True, - True, - "1.0.0", + ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - *( - # when prerelease must be False, & allow_zero_version is False, the version should be - # 1.0.0, with any change regardless of major_on_zero - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(scipy_chore_commits.__name__), - lazy_fixture(scipy_patch_commits.__name__), - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - ], - # Latest version for repo_with_single_branch_and_prereleases is - # currently 0.2.0 + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) + ), + ], + # Latest version for repo_with_single_branch_and_prereleases is + # currently 0.2.0 + ( + repo_w_trunk_only_n_prereleases_scipy_commits.__name__, + None, + ): [ + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits ( - repo_w_trunk_only_n_prereleases_scipy_commits.__name__, + lazy_fixture(scipy_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", None, - ): [ + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(scipy_patch_commits.__name__), + False, + False, + True, + "0.2.1", + None, + ), + *( # when allow_zero_version is True, - # prerelease is False, & major_on_zero is False, the version should be - # patch bumped as a prerelease version, when given patch level commits - ( - lazy_fixture(scipy_patch_commits.__name__), - True, - False, - True, - "0.2.1-rc.1", + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1", None) + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) + ), + *( + # when allow_zero_version is True, prerelease is True, & major_on_zero + # is False, the version should be minor bumped, when given commits of a + # minor or major level because major_on_zero = False + (commits, False, False, True, "0.3.0", None) + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) + ), + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(scipy_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + None, + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + None, + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1", None) + for major_on_zero in (True, False) + for commits in ( None, - ), - # when allow_zero_version is True, - # prerelease is False, & major_on_zero is False, the version should be - # patch bumped, when given patch level commits - ( + lazy_fixture(scipy_chore_commits.__name__), lazy_fixture(scipy_patch_commits.__name__), - False, - False, - True, - "0.2.1", - None, - ), - *( - # when allow_zero_version is True, - # prerelease is True, & major_on_zero is False, the version should be - # minor bumped as a prerelease version, when given commits of a minor or major level - (commits, True, False, True, "0.3.0-rc.1", None) - for commits in ( - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - *( - # when allow_zero_version is True, prerelease is True, & major_on_zero - # is False, the version should be minor bumped, when given commits of a - # minor or major level because major_on_zero = False - (commits, False, False, True, "0.3.0", None) - for commits in ( - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - # when prerelease is True, & major_on_zero is True, and allow_zero_version - # is True, the version should be bumped to 1.0.0 as a prerelease version, when - # given major level commits - ( + lazy_fixture(scipy_minor_commits.__name__), lazy_fixture(scipy_major_commits.__name__), - True, - True, - True, - "1.0.0-rc.1", - None, - ), - # when prerelease is False, & major_on_zero is True, and allow_zero_version - # is True, the version should be bumped to 1.0.0, when given major level commits - ( + ) + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0", None) + for major_on_zero in (True, False) + for commits in ( + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), lazy_fixture(scipy_major_commits.__name__), - False, - True, - True, - "1.0.0", - None, - ), - *( - # when prerelease is True, & allow_zero_version is False, the version should be - # bumped to 1.0.0 as a prerelease version, when given any/none commits - # because 0.x is no longer a valid version regardless of the major_on_zero value - (commits, True, major_on_zero, False, "1.0.0-rc.1", None) - for major_on_zero in (True, False) - for commits in ( - None, - lazy_fixture(scipy_chore_commits.__name__), - lazy_fixture(scipy_patch_commits.__name__), - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - *( - # when prerelease is True, & allow_zero_version is False, the version should be - # bumped to 1.0.0, when given any/none commits - # because 0.x is no longer a valid version regardless of the major_on_zero value - (commits, False, major_on_zero, False, "1.0.0", None) - for major_on_zero in (True, False) - for commits in ( - lazy_fixture(scipy_patch_commits.__name__), - lazy_fixture(scipy_minor_commits.__name__), - lazy_fixture(scipy_major_commits.__name__), - ) - ), - ], - }.items() - for ( - commit_messages, - prerelease, - major_on_zero, - allow_zero_version, - next_release_version, - branch_name, - ) in values # type: ignore[attr-defined] - ], + ) + ), + ], + }.items() + for ( + commit_messages, + prerelease, + major_on_zero, + allow_zero_version, + next_release_version, + branch_name, + ) in values # type: ignore[attr-defined] ], ), ) From 0363ea30bb9fcfc8b5747fea5a8ba1502bd1c4c6 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 16:42:52 -0700 Subject: [PATCH 3/7] test(cmd-version): fix release notes test implementation to avoid date change error --- tests/e2e/cmd_version/test_version_release_notes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/cmd_version/test_version_release_notes.py b/tests/e2e/cmd_version/test_version_release_notes.py index c185b76f2..ccd82dc77 100644 --- a/tests/e2e/cmd_version/test_version_release_notes.py +++ b/tests/e2e/cmd_version/test_version_release_notes.py @@ -1,9 +1,11 @@ from __future__ import annotations import os +from datetime import timezone 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.cli.commands.main import main @@ -134,13 +136,14 @@ def test_default_release_notes_license_statement( new_version = "0.1.0" # Setup + now_datetime = stable_now_date() repo_def = list(repo_result["definition"]) repo_def.append( { "action": RepoActionStep.RELEASE, "details": { "version": new_version, - "datetime": stable_now_date().isoformat(timespec="seconds"), + "datetime": now_datetime.isoformat(timespec="seconds"), }, } ) @@ -159,8 +162,9 @@ def test_default_release_notes_license_statement( ) # Act - cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-changelog", "--vcs-release"] - result = cli_runner.invoke(main, cli_cmd[1:]) + with freeze_time(now_datetime.astimezone(timezone.utc)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-changelog", "--vcs-release"] + result = cli_runner.invoke(main, cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) From 84b203f75d30f3047705bc669dbeae90f54e2cef Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 17:27:12 -0700 Subject: [PATCH 4/7] test(main): use easiest & common repo for non-comprehensive tests --- tests/e2e/test_main.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/tests/e2e/test_main.py b/tests/e2e/test_main.py index 361061021..42c04b118 100644 --- a/tests/e2e/test_main.py +++ b/tests/e2e/test_main.py @@ -13,10 +13,7 @@ from semantic_release.cli.commands.main import main from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD -from tests.fixtures import ( - repo_w_git_flow_w_alpha_prereleases_n_conventional_commits, - repo_w_no_tags_conventional_commits, -) +from tests.fixtures.repos import repo_w_no_tags_conventional_commits from tests.util import assert_exit_code, assert_successful_exit_code if TYPE_CHECKING: @@ -46,7 +43,7 @@ def test_main_no_args_prints_help_text(cli_runner: CliRunner): @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__)], + [lazy_fixture(repo_w_no_tags_conventional_commits.__name__)], ) def test_not_a_release_branch_exit_code( repo_result: BuiltRepoResult, cli_runner: CliRunner @@ -64,7 +61,7 @@ def test_not_a_release_branch_exit_code( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__)], + [lazy_fixture(repo_w_no_tags_conventional_commits.__name__)], ) def test_not_a_release_branch_exit_code_with_strict( repo_result: BuiltRepoResult, @@ -83,7 +80,7 @@ def test_not_a_release_branch_exit_code_with_strict( @pytest.mark.parametrize( "repo_result", - [lazy_fixture(repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__)], + [lazy_fixture(repo_w_no_tags_conventional_commits.__name__)], ) def test_not_a_release_branch_detached_head_exit_code( repo_result: BuiltRepoResult, @@ -129,9 +126,7 @@ def json_file_with_no_configuration_for_psr(tmp_path: Path) -> Path: return path -@pytest.mark.usefixtures( - repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__ -) +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_default_config_is_used_when_none_in_toml_config_file( cli_runner: CliRunner, toml_file_with_no_configuration_for_psr: Path, @@ -151,9 +146,7 @@ def test_default_config_is_used_when_none_in_toml_config_file( assert_successful_exit_code(result, cli_cmd) -@pytest.mark.usefixtures( - repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__ -) +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_default_config_is_used_when_none_in_json_config_file( cli_runner: CliRunner, json_file_with_no_configuration_for_psr: Path, @@ -173,9 +166,7 @@ def test_default_config_is_used_when_none_in_json_config_file( assert_successful_exit_code(result, cli_cmd) -@pytest.mark.usefixtures( - repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__ -) +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_errors_when_config_file_does_not_exist_and_passed_explicitly( cli_runner: CliRunner, ): From 8906d8e70467af1489d797ec8cb09b1f95e5d409 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 17 Feb 2025 00:20:49 -0700 Subject: [PATCH 5/7] feat(cmd-version): enable stamping of tag formatted versions into files (#1190) Resolves: #846 * build(deps): add `deprecated~=1.2` for deprecation notices & sphinx documentation * refactor(noop): simplify text output during `--noop` execution * chore(mypy): set mypy configuration to ignore `dotty_dict` missing types * test(declarations): update unit tests for full file modification w/ tag formatted versions * test(cmd-version): add version stamp test to validate tag formatted version stamping * docs(configuration): add usage information for tag format version stamping --- docs/configuration.rst | 132 ++++- pyproject.toml | 6 + src/semantic_release/cli/commands/version.py | 45 +- src/semantic_release/cli/config.py | 79 ++- src/semantic_release/cli/util.py | 2 +- src/semantic_release/version/declaration.py | 163 ++----- .../version/declarations/__init__.py | 0 .../version/declarations/enum.py | 12 + .../declarations/i_version_replacer.py | 67 +++ .../version/declarations/pattern.py | 241 ++++++++++ .../version/declarations/toml.py | 148 ++++++ tests/e2e/cmd_version/test_version_stamp.py | 130 ++++- .../version/declarations/__init__.py | 0 .../declarations/test_pattern_declaration.py | 454 ++++++++++++++++++ .../declarations/test_toml_declaration.py | 350 ++++++++++++++ .../version/test_declaration.py | 138 ------ 16 files changed, 1627 insertions(+), 340 deletions(-) create mode 100644 src/semantic_release/version/declarations/__init__.py create mode 100644 src/semantic_release/version/declarations/enum.py create mode 100644 src/semantic_release/version/declarations/i_version_replacer.py create mode 100644 src/semantic_release/version/declarations/pattern.py create mode 100644 src/semantic_release/version/declarations/toml.py create mode 100644 tests/unit/semantic_release/version/declarations/__init__.py create mode 100644 tests/unit/semantic_release/version/declarations/test_pattern_declaration.py create mode 100644 tests/unit/semantic_release/version/declarations/test_toml_declaration.py delete mode 100644 tests/unit/semantic_release/version/test_declaration.py diff --git a/docs/configuration.rst b/docs/configuration.rst index a777c8f21..7fc4838c4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1201,17 +1201,61 @@ Tags which do not match this format will not be considered as versions of your p **Type:** ``list[str]`` -Similar to :ref:`config-version_variables`, but allows the version number to be -identified safely in a toml file like ``pyproject.toml``, with each entry using -dotted notation to indicate the key for which the value represents the version: +This configuration option is similar to :ref:`config-version_variables`, but it uses +a TOML parser to interpret the data structure before, inserting the version. This +allows users to use dot-notation to specify the version via the logical structure +within the TOML file, which is more accurate than a pattern replace. + +The ``version_toml`` option is commonly used to update the version number in the project +definition file: ``pyproject.toml`` as seen in the example below. + +As of ${NEW_RELEASE_TAG}, the ``version_toml`` option accepts a colon-separated definition +with either 2 or 3 parts. The 2-part definition includes the file path and the version +parameter (in dot-notation). Newly with ${NEW_RELEASE_TAG}, it also accepts an optional +3rd part to allow configuration of the format type. + +**Available Format Types** + +- ``nf``: Number format (ex. ``1.2.3``) +- ``tf``: :ref:`Tag Format ` (ex. ``v1.2.3``) + +If the format type is not specified, it will default to the number format. + +**Example** .. code-block:: toml [semantic_release] version_toml = [ - "pyproject.toml:tool.poetry.version", + # "file:variable:[format_type]" + "pyproject.toml:tool.poetry.version", # Implied Default: Number format + "definition.toml:project.version:nf", # Number format + "definition.toml:project.release:tf", # Tag format ] +This configuration will result in the following changes: + +.. code-block:: diff + + diff a/pyproject.toml b/pyproject.toml + + [tool.poetry] + - version = "0.1.0" + + version = "0.2.0" + +.. code-block:: diff + + diff a/definition.toml b/definition.toml + + [project] + name = "example" + + - version = "0.1.0" + + version = "0.1.0" + + - release = "v0.1.0" + + release = "v0.2.0" + **Default:** ``[]`` ---- @@ -1223,17 +1267,74 @@ dotted notation to indicate the key for which the value represents the version: **Type:** ``list[str]`` -Each entry represents a location where the version is stored in the source code, -specified in ``file:variable`` format. For example: +The ``version_variables`` configuration option is a list of string definitions +that defines where the version number should be updated in the repository, when +a new version is released. + +As of ${NEW_RELEASE_TAG}, the ``version_variables`` option accepts a +colon-separated definition with either 2 or 3 parts. The 2-part definition includes +the file path and the variable name. Newly with ${NEW_RELEASE_TAG}, it also accepts +an optional 3rd part to allow configuration of the format type. + +**Available Format Types** + +- ``nf``: Number format (ex. ``1.2.3``) +- ``tf``: :ref:`Tag Format ` (ex. ``v1.2.3``) + +If the format type is not specified, it will default to the number format. + +Prior to ${NEW_RELEASE_TAG}, PSR only supports entries with the first 2-parts +as the tag format type was not available and would only replace numeric +version numbers. + +**Example** .. code-block:: toml [semantic_release] + tag_format = "v{version}" version_variables = [ - "src/semantic_release/__init__.py:__version__", - "docs/conf.py:version", + # "file:variable:format_type" + "src/semantic_release/__init__.py:__version__", # Implied Default: Number format + "docs/conf.py:version:nf", # Number format for sphinx docs + "kustomization.yml:newTag:tf", # Tag format ] +First, the ``__version__`` variable in ``src/semantic_release/__init__.py`` will be updated +with the next version using the `SemVer`_ number format. + +.. code-block:: diff + + diff a/src/semantic_release/__init__.py b/src/semantic_release/__init__.py + + - __version__ = "0.1.0" + + __version__ = "0.2.0" + +Then, the ``version`` variable in ``docs/conf.py`` will be updated with the next version +with the next version using the `SemVer`_ number format because of the explicit ``nf``. + +.. code-block:: diff + + diff a/docs/conf.py b/docs/conf.py + + - version = "0.1.0" + + version = "0.2.0" + +Lastly, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version +with the next version using the configured :ref:`config-tag_format` because the definition +included ``tf``. + +.. code-block:: diff + + diff a/kustomization.yml b/kustomization.yml + + images: + - name: repo/image + - newTag: v0.1.0 + + newTag: v0.2.0 + +**How It works** + Each version variable will be transformed into a Regular Expression that will be used to substitute the version number in the file. The replacement algorithm is **ONLY** a pattern match and replace. It will **NOT** evaluate the code nor will PSR understand @@ -1242,16 +1343,17 @@ any internal object structures (ie. ``file:object.version`` will not work). .. important:: The Regular Expression expects a version value to exist in the file to be replaced. It cannot be an empty string or a non-semver compliant string. If this is the very - first time you are using PSR, we recommend you set the version to ``0.0.0``. This - may become more flexible in the future with resolution of issue `#941`_. + first time you are using PSR, we recommend you set the version to ``0.0.0``. + + This may become more flexible in the future with resolution of issue `#941`_. .. _#941: https://github.com/python-semantic-release/python-semantic-release/issues/941 Given the pattern matching nature of this feature, the Regular Expression is able to -support most file formats as a variable declaration in most languages is very similar. -We specifically support Python, YAML, and JSON as these have been the most common -requests. This configuration option will also work regardless of file extension -because its only a pattern match. +support most file formats because of the similarity of variable declaration across +programming languages. PSR specifically supports Python, YAML, and JSON as these have +been the most commonly requested formats. This configuration option will also work +regardless of file extension because it looks for a matching pattern string. .. note:: This will also work for TOML but we recommend using :ref:`config-version_toml` for @@ -1264,3 +1366,5 @@ because its only a pattern match. both. This is a limitation of the pattern matching and not a bug. **Default:** ``[]`` + +.. _SemVer: https://semver.org/ diff --git a/pyproject.toml b/pyproject.toml index f0d1eb911..b11f2d7f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "pydantic ~= 2.0", "rich ~= 13.0", "shellingham ~= 1.5", + "Deprecated ~= 1.2", # Backport of deprecated decorator for python 3.8 ] [project.scripts] @@ -83,6 +84,7 @@ dev = [ ] mypy = [ "mypy == 1.15.0", + "types-Deprecated ~= 1.2", "types-requests ~= 2.32.0", "types-pyyaml ~= 6.0", ] @@ -201,6 +203,10 @@ ignore_missing_imports = true module = "shellingham" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "dotty_dict" +ignore_missing_imports = true + [tool.ruff] line-length = 88 target-version = "py38" diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 104faa9fe..86d209937 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -39,12 +39,12 @@ if TYPE_CHECKING: # pragma: no cover from pathlib import Path - from typing import Iterable, Mapping + from typing import Mapping, Sequence from git.refs.tag import Tag from semantic_release.cli.cli_context import CliContextObj - from semantic_release.version.declaration import VersionDeclarationABC + from semantic_release.version.declaration import IVersionReplacer from semantic_release.version.version import Version @@ -135,28 +135,43 @@ def version_from_forced_level( def apply_version_to_source_files( repo_dir: Path, - version_declarations: Iterable[VersionDeclarationABC], + version_declarations: Sequence[IVersionReplacer], version: Version, noop: bool = False, ) -> list[str]: - paths = [ - str(declaration.path.resolve().relative_to(repo_dir)) - for declaration in version_declarations + if len(version_declarations) < 1: + return [] + + if not noop: + log.debug("Updating version %s in repository files...", version) + + paths = list( + map( + lambda decl, new_version=version, noop=noop: ( # type: ignore[misc] + decl.update_file_w_version(new_version=new_version, noop=noop) + ), + version_declarations, + ) + ) + + repo_filepaths = [ + str(updated_file.relative_to(repo_dir)) + for updated_file in paths + if updated_file is not None ] if noop: noop_report( - "would have updated versions in the following paths:" - + "".join(f"\n {path}" for path in paths) + str.join( + "", + [ + "would have updated versions in the following paths:", + *[f"\n {filepath}" for filepath in repo_filepaths], + ], + ) ) - return paths - - log.debug("writing version %s to source paths %s", version, paths) - for declaration in version_declarations: - new_content = declaration.replace(new_version=version) - declaration.path.write_text(new_content) - return paths + return repo_filepaths def shell( diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 721028281..41ac02058 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -46,7 +46,7 @@ ScipyCommitParser, TagCommitParser, ) -from semantic_release.const import COMMIT_MESSAGE, DEFAULT_COMMIT_AUTHOR, SEMVER_REGEX +from semantic_release.const import COMMIT_MESSAGE, DEFAULT_COMMIT_AUTHOR from semantic_release.errors import ( DetachedHeadGitError, InvalidConfiguration, @@ -55,11 +55,9 @@ ParserLoadError, ) from semantic_release.helpers import dynamic_import -from semantic_release.version.declaration import ( - PatternVersionDeclaration, - TomlVersionDeclaration, - VersionDeclarationABC, -) +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.declarations.pattern import PatternVersionDeclaration +from semantic_release.version.declarations.toml import TomlVersionDeclaration from semantic_release.version.translator import VersionTranslator log = logging.getLogger(__name__) @@ -555,7 +553,7 @@ class RuntimeContext: commit_author: Actor commit_message: str changelog_excluded_commit_patterns: Tuple[Pattern[str], ...] - version_declarations: Tuple[VersionDeclarationABC, ...] + version_declarations: Tuple[IVersionReplacer, ...] hvcs_client: hvcs.HvcsBase changelog_insertion_flag: str changelog_mask_initial_release: bool @@ -738,44 +736,41 @@ def from_raw_config( # noqa: C901 commit_author = Actor(*_commit_author_valid.groups()) - version_declarations: list[VersionDeclarationABC] = [] - for decl in () if raw.version_toml is None else raw.version_toml: - try: - path, search_text = decl.split(":", maxsplit=1) - # VersionDeclarationABC handles path existence check - vd = TomlVersionDeclaration(path, search_text) - except ValueError as exc: - log.exception("Invalid TOML declaration %r", decl) - raise InvalidConfiguration( - f"Invalid TOML declaration {decl!r}" - ) from exc - - version_declarations.append(vd) - - for decl in () if raw.version_variables is None else raw.version_variables: - try: - path, variable = decl.split(":", maxsplit=1) - # VersionDeclarationABC handles path existence check - search_text = str.join( - "", + version_declarations: list[IVersionReplacer] = [] + + try: + version_declarations.extend( + TomlVersionDeclaration.from_string_definition(definition) + for definition in iter(raw.version_toml or ()) + ) + except ValueError as err: + raise InvalidConfiguration( + str.join( + "\n", [ - # Supports optional matching quotations around variable name - # Negative lookbehind to ensure we don't match part of a variable name - f"""(?x)(?P['"])?(?['"])?(?P{SEMVER_REGEX.pattern})(?P=quote2)?""", + "Invalid 'version_toml' configuration", + str(err), ], ) - pd = PatternVersionDeclaration(path, search_text) - except ValueError as exc: - log.exception("Invalid variable declaration %r", decl) - raise InvalidConfiguration( - f"Invalid variable declaration {decl!r}" - ) from exc - - version_declarations.append(pd) + ) from err + + try: + version_declarations.extend( + PatternVersionDeclaration.from_string_definition( + definition, raw.tag_format + ) + for definition in iter(raw.version_variables or ()) + ) + except ValueError as err: + raise InvalidConfiguration( + str.join( + "\n", + [ + "Invalid 'version_variables' configuration", + str(err), + ], + ) + ) from err # Provide warnings if the token is missing if not raw.remote.token: diff --git a/src/semantic_release/cli/util.py b/src/semantic_release/cli/util.py index c1c01b79c..97d264b02 100644 --- a/src/semantic_release/cli/util.py +++ b/src/semantic_release/cli/util.py @@ -28,7 +28,7 @@ def noop_report(msg: str) -> None: Rich-prints a msg with a standard prefix to report when an action is not being taken due to a "noop" flag """ - fullmsg = "[bold cyan]:shield: semantic-release 'noop' mode is enabled! " + msg + fullmsg = "[bold cyan][:shield: NOP] " + msg rprint(fullmsg) diff --git a/src/semantic_release/version/declaration.py b/src/semantic_release/version/declaration.py index 89f310d8e..92da001a1 100644 --- a/src/semantic_release/version/declaration.py +++ b/src/semantic_release/version/declaration.py @@ -1,19 +1,45 @@ from __future__ import annotations -import logging -import re +# TODO: Remove v10 from abc import ABC, abstractmethod +from logging import getLogger from pathlib import Path -from typing import Any, Dict, cast - -import tomlkit -from dotty_dict import Dotty # type: ignore[import] - -from semantic_release.version.version import Version - -log = logging.getLogger(__name__) - - +from typing import TYPE_CHECKING + +from deprecated.sphinx import deprecated + +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.declarations.pattern import PatternVersionDeclaration +from semantic_release.version.declarations.toml import TomlVersionDeclaration + +if TYPE_CHECKING: # pragma: no cover + from typing import Any + + from semantic_release.version.version import Version + + +# Globals +__all__ = [ + "IVersionReplacer", + "VersionStampType", + "PatternVersionDeclaration", + "TomlVersionDeclaration", + "VersionDeclarationABC", +] +log = getLogger(__name__) + + +@deprecated( + version="9.20.0", + reason=str.join( + " ", + [ + "Refactored to composition paradigm using the new IVersionReplacer interface.", + "This class will be removed in a future release", + ], + ), +) class VersionDeclarationABC(ABC): """ ABC for classes representing a location in which a version is declared somewhere @@ -86,116 +112,3 @@ def write(self, content: str) -> None: log.debug("writing content to %r", self.path.resolve()) self.path.write_text(content) self._content = None - - -class TomlVersionDeclaration(VersionDeclarationABC): - """VersionDeclarationABC implementation which manages toml-format source files.""" - - def _load(self) -> Dotty: - """Load the content of the source file into a Dotty for easier searching""" - loaded = tomlkit.loads(self.content) - return Dotty(loaded) - - def parse(self) -> set[Version]: - """Look for the version in the source content""" - content = self._load() - maybe_version: str = content.get(self.search_text) # type: ignore[return-value] - if maybe_version is not None: - log.debug( - "Found a key %r that looks like a version (%r)", - self.search_text, - maybe_version, - ) - valid_version = Version.parse(maybe_version) - return {valid_version} if valid_version else set() - # Maybe in future raise error if not found? - return set() - - def replace(self, new_version: Version) -> str: - """ - Replace the version in the source content with `new_version`, and return the - updated content. - """ - content = self._load() - if self.search_text in content: - log.info( - "found %r in source file contents, replacing with %s", - self.search_text, - new_version, - ) - content[self.search_text] = str(new_version) - - return tomlkit.dumps(cast(Dict[str, Any], content)) - - -class PatternVersionDeclaration(VersionDeclarationABC): - """ - VersionDeclarationABC implementation representing a version number in a particular - file. The version number is identified by a regular expression, which should be - provided in `search_text`. - """ - - _VERSION_GROUP_NAME = "version" - - def __init__(self, path: Path | str, search_text: str) -> None: - super().__init__(path, search_text) - self.search_re = re.compile(self.search_text, flags=re.MULTILINE) - if self._VERSION_GROUP_NAME not in self.search_re.groupindex: - raise ValueError( - f"Invalid search text {self.search_text!r}; must use 'version' as a " - "named group, for example (?P...) . For more info on named " - "groups see https://docs.python.org/3/library/re.html" - ) - - # The pattern should be a regular expression with a single group, - # containing the version to replace. - def parse(self) -> set[Version]: - """ - Return the versions matching this pattern. - Because a pattern can match in multiple places, this method returns a - set of matches. Generally, there should only be one element in this - set (i.e. even if the version is specified in multiple places, it - should be the same version in each place), but it falls on the caller - to check for this condition. - """ - versions = { - Version.parse(m.group(self._VERSION_GROUP_NAME)) - for m in self.search_re.finditer(self.content, re.MULTILINE) - } - - log.debug( - "Parsing current version: path=%r pattern=%r num_matches=%s", - self.path.resolve(), - self.search_text, - len(versions), - ) - return versions - - def replace(self, new_version: Version) -> str: - """ - Update the versions. - This method reads the underlying file, replaces each occurrence of the - matched pattern, then writes the updated file. - :param new_version: The new version number as a `Version` instance - """ - n = 0 - - def swap_version(m: re.Match[str]) -> str: - nonlocal n - n += 1 - s = m.string - i, j = m.span() - log.debug("match spans characters %s:%s", i, j) - ii, jj = m.span(self._VERSION_GROUP_NAME) - log.debug("version group spans characters %s:%s", ii, jj) - return s[i:ii] + str(new_version) + s[jj:j] - - new_content, n_matches = self.search_re.subn( - swap_version, self.content, re.MULTILINE - ) - - log.debug( - "path=%r pattern=%r num_matches=%r", self.path, self.search_text, n_matches - ) - - return new_content diff --git a/src/semantic_release/version/declarations/__init__.py b/src/semantic_release/version/declarations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/semantic_release/version/declarations/enum.py b/src/semantic_release/version/declarations/enum.py new file mode 100644 index 000000000..848430f22 --- /dev/null +++ b/src/semantic_release/version/declarations/enum.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from enum import Enum + + +class VersionStampType(str, Enum): + """Enum for the type of version declaration""" + + # The version is a number format, e.g. 1.2.3 + NUMBER_FORMAT = "nf" + + TAG_FORMAT = "tf" diff --git a/src/semantic_release/version/declarations/i_version_replacer.py b/src/semantic_release/version/declarations/i_version_replacer.py new file mode 100644 index 000000000..fcee56564 --- /dev/null +++ b/src/semantic_release/version/declarations/i_version_replacer.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from pathlib import Path + + from semantic_release.version.version import Version + + +class IVersionReplacer(metaclass=ABCMeta): + """ + Interface for subclasses that replace a version string in a source file. + + Methods generally have a base implementation are implemented here but + likely just provide a not-supported message but return gracefully + + This class cannot be instantiated directly but must be inherited from + and implement the designated abstract methods. + """ + + @classmethod + def __subclasshook__(cls, subclass: type) -> bool: + # Validate that the subclass implements all of the abstract methods. + # This supports isinstance and issubclass checks. + return bool( + cls is IVersionReplacer + and all( + bool(hasattr(subclass, method) and callable(getattr(subclass, method))) + for method in IVersionReplacer.__abstractmethods__ + ) + ) + + @abstractmethod + def parse(self) -> set[Version]: + """ + Return a set of the versions which can be parsed from the file. + Because a source can match in multiple places, this method returns a + set of matches. Generally, there should only be one element in this + set (i.e. even if the version is specified in multiple places, it + should be the same version in each place), but enforcing that condition + is not mandatory or expected. + """ + raise NotImplementedError # pragma: no cover + + @abstractmethod + def replace(self, new_version: Version) -> str: + """ + Replace the version in the source content with `new_version`, and return + the updated content. + + :param new_version: The new version number as a `Version` instance + """ + raise NotImplementedError # pragma: no cover + + @abstractmethod + def update_file_w_version( + self, new_version: Version, noop: bool = False + ) -> Path | None: + """ + This method reads the underlying file, replaces each occurrence of the + matched pattern, then writes the updated file. + + :param new_version: The new version number as a `Version` instance + """ + raise NotImplementedError # pragma: no cover diff --git a/src/semantic_release/version/declarations/pattern.py b/src/semantic_release/version/declarations/pattern.py new file mode 100644 index 000000000..73f67b465 --- /dev/null +++ b/src/semantic_release/version/declarations/pattern.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from logging import getLogger +from pathlib import Path +from re import ( + MULTILINE, + compile as regexp, + error as RegExpError, # noqa: N812 + escape as regex_escape, +) +from typing import TYPE_CHECKING + +from deprecated.sphinx import deprecated + +from semantic_release.cli.util import noop_report +from semantic_release.const import SEMVER_REGEX +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.version import Version + +if TYPE_CHECKING: # pragma: no cover + from re import Match + + +log = getLogger(__name__) + + +class VersionSwapper: + """Callable to replace a version number in a string with a new version number.""" + + def __init__(self, new_version_str: str, group_match_name: str) -> None: + self.version_str = new_version_str + self.group_match_name = group_match_name + + def __call__(self, match: Match[str]) -> str: + i, j = match.span() + ii, jj = match.span(self.group_match_name) + return f"{match.string[i:ii]}{self.version_str}{match.string[jj:j]}" + + +class PatternVersionDeclaration(IVersionReplacer): + """ + VersionDeclarationABC implementation representing a version number in a particular + file. The version number is identified by a regular expression, which should be + provided in `search_text`. + """ + + _VERSION_GROUP_NAME = "version" + + def __init__( + self, path: Path | str, search_text: str, stamp_format: VersionStampType + ) -> None: + self._content: str | None = None + self._path = Path(path).resolve() + self._stamp_format = stamp_format + + try: + self._search_pattern = regexp(search_text, flags=MULTILINE) + except RegExpError as err: + raise ValueError( + f"Invalid regular expression for search text: {search_text!r}" + ) from err + + if self._VERSION_GROUP_NAME not in self._search_pattern.groupindex: + raise ValueError( + str.join( + " ", + [ + f"Invalid search text {search_text!r}; must use", + f"'{self._VERSION_GROUP_NAME}' as a named group, for example", + f"(?P<{self._VERSION_GROUP_NAME}>...) . For more info on named", + "groups see https://docs.python.org/3/library/re.html", + ], + ) + ) + + @property + def content(self) -> str: + """A cached property that stores the content of the configured source file.""" + if self._content is None: + log.debug("No content stored, reading from source file %s", self._path) + + if not self._path.exists(): + raise FileNotFoundError(f"path {self._path!r} does not exist") + + self._content = self._path.read_text() + + return self._content + + @content.deleter + def content(self) -> None: + self._content = None + + @deprecated( + version="9.20.0", + reason="Function is unused and will be removed in a future release", + ) + def parse(self) -> set[Version]: # pragma: no cover + """ + Return the versions matching this pattern. + Because a pattern can match in multiple places, this method returns a + set of matches. Generally, there should only be one element in this + set (i.e. even if the version is specified in multiple places, it + should be the same version in each place), but it falls on the caller + to check for this condition. + """ + versions = { + Version.parse(m.group(self._VERSION_GROUP_NAME)) + for m in self._search_pattern.finditer(self.content) + } + + log.debug( + "Parsing current version: path=%r pattern=%r num_matches=%s", + self._path.resolve(), + self._search_pattern, + len(versions), + ) + return versions + + def replace(self, new_version: Version) -> str: + """ + Replace the version in the source content with `new_version`, and return + the updated content. + + :param new_version: The new version number as a `Version` instance + """ + new_content, n_matches = self._search_pattern.subn( + VersionSwapper( + new_version_str=( + new_version.as_tag() + if self._stamp_format == VersionStampType.TAG_FORMAT + else str(new_version) + ), + group_match_name=self._VERSION_GROUP_NAME, + ), + self.content, + ) + + log.debug( + "path=%r pattern=%r num_matches=%r", + self._path, + self._search_pattern, + n_matches, + ) + + return new_content + + def update_file_w_version( + self, new_version: Version, noop: bool = False + ) -> Path | None: + if noop: + if not self._path.exists(): + noop_report( + f"FILE NOT FOUND: cannot stamp version in non-existent file {self._path}", + ) + return None + + if len(self._search_pattern.findall(self.content)) < 1: + noop_report( + f"VERSION PATTERN NOT FOUND: no version to stamp in file {self._path}", + ) + return None + + return self._path + + new_content = self.replace(new_version) + if new_content == self.content: + return None + + self._path.write_text(new_content) + del self.content + + return self._path + + @classmethod + def from_string_definition( + cls, replacement_def: str, tag_format: str + ) -> PatternVersionDeclaration: + """ + create an instance of self from a string representing one item + of the "version_variables" list in the configuration + """ + parts = replacement_def.split(":", maxsplit=2) + + if len(parts) <= 1: + raise ValueError( + f"Invalid replacement definition {replacement_def!r}, missing ':'" + ) + + if len(parts) == 2: + # apply default version_type of "number_format" (ie. "1.2.3") + parts = [*parts, VersionStampType.NUMBER_FORMAT.value] + + path, variable, version_type = parts + + try: + stamp_type = VersionStampType(version_type) + except ValueError as err: + raise ValueError( + str.join( + " ", + [ + "Invalid stamp type, must be one of:", + str.join(", ", [e.value for e in VersionStampType]), + ], + ) + ) from err + + # DEFAULT: naked (no v-prefixed) semver version + value_replace_pattern_str = ( + f"(?P<{cls._VERSION_GROUP_NAME}>{SEMVER_REGEX.pattern})" + ) + + if version_type == VersionStampType.TAG_FORMAT.value: + tag_parts = tag_format.strip().split(r"{version}", maxsplit=1) + value_replace_pattern_str = str.join( + "", + [ + f"(?P<{cls._VERSION_GROUP_NAME}>", + regex_escape(tag_parts[0]), + SEMVER_REGEX.pattern, + (regex_escape(tag_parts[1]) if len(tag_parts) > 1 else ""), + ")", + ], + ) + + search_text = str.join( + "", + [ + # Supports optional matching quotations around variable name + # Negative lookbehind to ensure we don't match part of a variable name + f"""(?x)(?P['"])?(?['"])?{value_replace_pattern_str}(?P=quote2)?""", + ], + ) + + return cls(path, search_text, stamp_type) diff --git a/src/semantic_release/version/declarations/toml.py b/src/semantic_release/version/declarations/toml.py new file mode 100644 index 000000000..ed9542870 --- /dev/null +++ b/src/semantic_release/version/declarations/toml.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from logging import getLogger +from pathlib import Path +from typing import Any, Dict, cast + +import tomlkit +from deprecated.sphinx import deprecated +from dotty_dict import Dotty + +from semantic_release.cli.util import noop_report +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.version import Version + +# globals +log = getLogger(__name__) + + +class TomlVersionDeclaration(IVersionReplacer): + def __init__( + self, path: Path | str, search_text: str, stamp_format: VersionStampType + ) -> None: + self._content: str | None = None + self._path = Path(path).resolve() + self._stamp_format = stamp_format + self._search_text = search_text + + @property + def content(self) -> str: + """A cached property that stores the content of the configured source file.""" + if self._content is None: + log.debug("No content stored, reading from source file %s", self._path) + + if not self._path.exists(): + raise FileNotFoundError(f"path {self._path!r} does not exist") + + self._content = self._path.read_text() + + return self._content + + @content.deleter + def content(self) -> None: + self._content = None + + @deprecated( + version="9.20.0", + reason="Function is unused and will be removed in a future release", + ) + def parse(self) -> set[Version]: # pragma: no cover + """Look for the version in the source content""" + content = self._load() + maybe_version: str = content.get(self._search_text) # type: ignore[return-value] + if maybe_version is not None: + log.debug( + "Found a key %r that looks like a version (%r)", + self._search_text, + maybe_version, + ) + valid_version = Version.parse(maybe_version) + return {valid_version} if valid_version else set() + # Maybe in future raise error if not found? + return set() + + def replace(self, new_version: Version) -> str: + """ + Replace the version in the source content with `new_version`, and return the + updated content. + """ + content = self._load() + if self._search_text in content: + log.info( + "found %r in source file contents, replacing with %s", + self._search_text, + new_version, + ) + content[self._search_text] = ( + new_version.as_tag() + if self._stamp_format == VersionStampType.TAG_FORMAT + else str(new_version) + ) + + return tomlkit.dumps(cast(Dict[str, Any], content)) + + def _load(self) -> Dotty: + """Load the content of the source file into a Dotty for easier searching""" + return Dotty(tomlkit.loads(self.content)) + + def update_file_w_version( + self, new_version: Version, noop: bool = False + ) -> Path | None: + if noop: + if not self._path.exists(): + noop_report( + f"FILE NOT FOUND: cannot stamp version in non-existent file {self._path!r}", + ) + return None + + if self._search_text not in self._load(): + noop_report( + f"VERSION PATTERN NOT FOUND: no version to stamp in file {self._path!r}", + ) + return None + + return self._path + + new_content = self.replace(new_version) + if new_content == self.content: + return None + + self._path.write_text(new_content) + del self.content + + return self._path + + @classmethod + def from_string_definition(cls, replacement_def: str) -> TomlVersionDeclaration: + """ + create an instance of self from a string representing one item + of the "version_toml" list in the configuration + """ + parts = replacement_def.split(":", maxsplit=2) + + if len(parts) <= 1: + raise ValueError( + f"Invalid TOML replacement definition {replacement_def!r}, missing ':'" + ) + + if len(parts) == 2: + # apply default version_type of "number_format" (ie. "1.2.3") + parts = [*parts, VersionStampType.NUMBER_FORMAT.value] + + path, search_text, version_type = parts + + try: + stamp_type = VersionStampType(version_type) + except ValueError as err: + raise ValueError( + str.join( + " ", + [ + "Invalid stamp type, must be one of:", + str.join(", ", [e.value for e in VersionStampType]), + ], + ) + ) from err + + return cls(path, search_text, stamp_type) diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index c95c6f813..c63d5b66c 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -8,9 +8,11 @@ import pytest import tomlkit import yaml +from dotty_dict import Dotty from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release.cli.commands.main import main +from semantic_release.version.declarations.enum import VersionStampType from tests.const import EXAMPLE_PROJECT_NAME, MAIN_PROG_NAME, VERSION_SUBCMD from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import ( @@ -122,7 +124,7 @@ def test_version_only_stamp_version( # no push as it should be turned off automatically assert mocked_git_push.call_count == 0 - assert post_mocker.call_count == 0 # no vcs release creation occured + assert post_mocker.call_count == 0 # no vcs release creation occurred # Files that should receive version change assert expected_changed_files == differing_files @@ -174,6 +176,62 @@ def test_stamp_version_variables_python( assert new_version == version_py_after +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_toml( + cli_runner: CliRunner, + update_pyproject_toml: UpdatePyprojectTomlFn, + default_tag_format_str: str, +) -> None: + orig_version = "0.0.0" + new_version = "0.1.0" + orig_release = default_tag_format_str.format(version=orig_version) + new_release = default_tag_format_str.format(version=new_version) + target_file = Path("example.toml") + orig_toml = dedent( + f"""\ + [package] + name = "example" + version = "{orig_version}" + release = "{orig_release}" + date-released = "1970-01-01" + """ + ) + + orig_toml_obj = Dotty(tomlkit.parse(orig_toml)) + + # Write initial text in file + target_file.write_text(orig_toml) + + # Set configuration to modify the yaml file + update_pyproject_toml( + "tool.semantic_release.version_toml", + [ + f"{target_file}:package.version:{VersionStampType.NUMBER_FORMAT.value}", + f"{target_file}:package.release:{VersionStampType.TAG_FORMAT.value}", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = cli_runner.invoke(main, cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + resulting_toml_obj = Dotty(tomlkit.parse(target_file.read_text())) + + # Check the version was updated + assert new_version == resulting_toml_obj["package.version"] + assert new_release == resulting_toml_obj["package.release"] + + # Check the rest of the content is the same (by resetting the version & comparing) + resulting_toml_obj["package.version"] = orig_version + resulting_toml_obj["package.release"] = orig_release + + assert orig_toml_obj == resulting_toml_obj + + @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml( cli_runner: CliRunner, @@ -211,7 +269,7 @@ def test_stamp_version_variables_yaml( # Check the version was updated assert new_version == resulting_yaml_obj["version"] - # Check the rest of the content is the same (by reseting the version & comparing) + # Check the rest of the content is the same (by resetting the version & comparing) resulting_yaml_obj["version"] = orig_version assert yaml.safe_load(orig_yaml) == resulting_yaml_obj @@ -222,10 +280,16 @@ def test_stamp_version_variables_yaml_cff( cli_runner: CliRunner, update_pyproject_toml: UpdatePyprojectTomlFn, ) -> None: + """ + Given a yaml file with a top level version directive, + When the version command is run, + Then the version is updated in the file and the rest of the content is unchanged & parsable + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/962 + """ orig_version = "0.0.0" new_version = "0.1.0" target_file = Path("CITATION.cff") - # Derived format from python-semantic-release/python-semantic-release#962 orig_yaml = dedent( f"""\ --- @@ -261,7 +325,7 @@ def test_stamp_version_variables_yaml_cff( # Check the version was updated assert new_version == resulting_yaml_obj["version"] - # Check the rest of the content is the same (by reseting the version & comparing) + # Check the rest of the content is the same (by resetting the version & comparing) resulting_yaml_obj["version"] = orig_version assert yaml.safe_load(orig_yaml) == resulting_yaml_obj @@ -303,7 +367,63 @@ def test_stamp_version_variables_json( # Check the version was updated assert new_version == resulting_json_obj["version"] - # Check the rest of the content is the same (by reseting the version & comparing) + # Check the rest of the content is the same (by resetting the version & comparing) resulting_json_obj["version"] = orig_version assert orig_json == resulting_json_obj + + +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_variables_yaml_kustomization_container_spec( + cli_runner: CliRunner, + update_pyproject_toml: UpdatePyprojectTomlFn, + default_tag_format_str: str, +) -> None: + """ + Given a yaml file with directives that expect a vX.Y.Z version tag declarations, + When a version is stamped and configured to stamp the version using the tag format, + Then the file is updated with the new version in the tag format + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/846 + """ + orig_version = "0.0.0" + new_version = "0.1.0" + target_file = Path("kustomization.yaml") + orig_yaml = dedent( + f"""\ + images: + - name: repo/image + newTag: {default_tag_format_str.format(version=orig_version)} + """ + ) + expected_new_tag_value = default_tag_format_str.format(version=new_version) + + # Setup: Write initial text in file + target_file.write_text(orig_yaml) + + # Setup: Set configuration to modify the yaml file + update_pyproject_toml( + "tool.semantic_release.version_variables", + [ + f"{target_file}:newTag:{VersionStampType.TAG_FORMAT.value}", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = cli_runner.invoke(main, cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + resulting_yaml_obj = yaml.safe_load(target_file.read_text()) + + # Check the version was updated + assert expected_new_tag_value == resulting_yaml_obj["images"][0]["newTag"] + + # Check the rest of the content is the same (by resetting the version & comparing) + original_yaml_obj = yaml.safe_load(orig_yaml) + resulting_yaml_obj["images"][0]["newTag"] = original_yaml_obj["images"][0]["newTag"] + + assert original_yaml_obj == resulting_yaml_obj diff --git a/tests/unit/semantic_release/version/declarations/__init__.py b/tests/unit/semantic_release/version/declarations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py new file mode 100644 index 000000000..b49f87fa0 --- /dev/null +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -0,0 +1,454 @@ +from __future__ import annotations + +from pathlib import Path +from re import compile as regexp +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.declarations.pattern import PatternVersionDeclaration +from semantic_release.version.version import Version + +from tests.fixtures.git_repo import default_tag_format_str + +if TYPE_CHECKING: + from re import Pattern + + +def test_pattern_declaration_is_version_replacer(): + """ + Given the class PatternVersionDeclaration or an instance of it, + When the class is evaluated as a subclass or an instance of, + Then the evaluation is true + """ + assert issubclass(PatternVersionDeclaration, IVersionReplacer) + + pattern_instance = PatternVersionDeclaration( + "file", r"^version = (?P.*)", VersionStampType.NUMBER_FORMAT + ) + assert isinstance(pattern_instance, IVersionReplacer) + + +@pytest.mark.parametrize( + str.join( + ", ", + [ + "replacement_def", + "tag_format", + "starting_contents", + "resulting_contents", + "next_version", + "test_file", + ], + ), + [ + pytest.param( + replacement_def, + tag_format, + starting_contents, + resulting_contents, + next_version, + test_file, + id=test_id, + ) + for test_file in ["test_file"] + for next_version in ["1.2.3"] + for test_id, replacement_def, tag_format, starting_contents, resulting_contents in [ + ( + "Default number format for python string variable", + f"{test_file}:__version__", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with single quotes + """__version__ = '1.0.0'""", + f"""__version__ = '{next_version}'""", + ), + ( + "Explicit number format for python string variable", + f"{test_file}:__version__:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with single quotes + """__version__ = '1.0.0'""", + f"""__version__ = '{next_version}'""", + ), + ( + "Using default tag format for python string variable", + f"{test_file}:__version__:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with single quotes + """__version__ = 'v1.0.0'""", + f"""__version__ = 'v{next_version}'""", + ), + ( + "Using custom tag format for python string variable", + f"{test_file}:__version__:{VersionStampType.TAG_FORMAT.value}", + "module-v{version}", + # Uses equals separator with double quotes + '''__version__ = "module-v1.0.0"''', + f'''__version__ = "module-v{next_version}"''', + ), + ( + # Based on https://github.com/python-semantic-release/python-semantic-release/issues/846 + "Using default tag format for multi-line yaml", + f"{test_file}:newTag:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator without quotes + dedent( + """\ + # kustomization.yaml + images: + - name: repo/image + newTag: v1.0.0 + """ + ), + dedent( + f"""\ + # kustomization.yaml + images: + - name: repo/image + newTag: v{next_version} + """ + ), + ), + ( + # Based on https://github.com/python-semantic-release/python-semantic-release/issues/846 + "Using custom tag format for multi-line yaml", + f"{test_file}:newTag:{VersionStampType.TAG_FORMAT.value}", + "module-v{version}", + # Uses colon separator without quotes + dedent( + """\ + # kustomization.yaml + images: + - name: repo/image + newTag: module-v1.0.0 + """ + ), + dedent( + f"""\ + # kustomization.yaml + images: + - name: repo/image + newTag: module-v{next_version} + """ + ), + ), + ( + "Explicit number format for python walrus string variable", + f"{test_file}:version:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses walrus separator with single quotes + """if version := '1.0.0': """, + f"""if version := '{next_version}': """, + ), + ( + "Using default number format for multi-line & quoted json", + f"{test_file}:version:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + { + "version": "1.0.0" + } + """ + ), + dedent( + f"""\ + {{ + "version": "{next_version}" + }} + """ + ), + ), + ( + "Using default tag format for multi-line & quoted json", + f"{test_file}:version:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + { + "version": "v1.0.0" + } + """ + ), + dedent( + f"""\ + {{ + "version": "v{next_version}" + }} + """ + ), + ), + ] + ], +) +def test_pattern_declaration_from_definition( + replacement_def: str, + tag_format: str, + starting_contents: str, + resulting_contents: str, + next_version: str, + test_file: str, + change_to_ex_proj_dir: None, +): + """ + Given a file with a formatted version string, + When update_file_w_version() is called with a new version, + Then the file is updated with the new version string in the specified tag or number format + + Version variables can be separated by either "=", ":", or ':=' with optional whitespace + between operator and variable name. The variable name or values can also be wrapped in either + single or double quotes. + """ + # Setup: create file with initial contents + expected_filepath = Path(test_file).resolve() + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = PatternVersionDeclaration.from_string_definition( + replacement_def, + tag_format, + ) + + # Act: apply version change + actual_file_modified = version_replacer.update_file_w_version( + new_version=Version.parse(next_version, tag_format=tag_format), + noop=False, + ) + + # Evaluate + actual_contents = Path(test_file).read_text() + assert resulting_contents == actual_contents + assert expected_filepath == actual_file_modified + + +def test_pattern_declaration_no_file_change( + default_tag_format_str: str, + change_to_ex_proj_dir: None, +): + """ + Given a configured stamp file is already up-to-date, + When update_file_w_version() is called with the same version, + Then the file is not modified and no path is returned + """ + test_file = "test_file" + expected_filepath = Path(test_file).resolve() + next_version = Version.parse("1.2.3", tag_format=default_tag_format_str) + starting_contents = f"""__version__ = '{next_version}'\n""" + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = PatternVersionDeclaration.from_string_definition( + f"{test_file}:__version__:{VersionStampType.NUMBER_FORMAT.value}", + tag_format=default_tag_format_str, + ) + + # Act: apply version change + file_modified = version_replacer.update_file_w_version( + new_version=next_version, + noop=False, + ) + + # Evaluate + actual_contents = expected_filepath.read_text() + assert starting_contents == actual_contents + assert file_modified is None + + +def test_pattern_declaration_error_on_missing_file( + default_tag_format_str: str, +): + # Initialization should not fail or do anything intensive + version_replacer = PatternVersionDeclaration.from_string_definition( + "nonexistent_file:__version__", + tag_format=default_tag_format_str, + ) + + with pytest.raises(FileNotFoundError): + version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3", tag_format=default_tag_format_str), + noop=False, + ) + + +def test_pattern_declaration_no_version_in_file( + default_tag_format_str: str, + change_to_ex_proj_dir: None, +): + test_file = "test_file" + expected_filepath = Path(test_file).resolve() + starting_contents = """other content\n""" + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = PatternVersionDeclaration.from_string_definition( + f"{test_file}:__version__:{VersionStampType.NUMBER_FORMAT.value}", + tag_format=default_tag_format_str, + ) + + file_modified = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3", tag_format=default_tag_format_str), + noop=False, + ) + + # Evaluate + actual_contents = expected_filepath.read_text() + assert file_modified is None + assert starting_contents == actual_contents + + +def test_pattern_declaration_noop_is_noop( + default_tag_format_str: str, + change_to_ex_proj_dir: None, +): + test_file = "test_file" + expected_filepath = Path(test_file).resolve() + starting_contents = """__version__ = '1.0.0'\n""" + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = PatternVersionDeclaration.from_string_definition( + f"{test_file}:__version__:{VersionStampType.NUMBER_FORMAT.value}", + tag_format=default_tag_format_str, + ) + + # Act: apply version change + file_modified = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3", tag_format=default_tag_format_str), + noop=True, + ) + + # Evaluate + actual_contents = Path(test_file).read_text() + assert starting_contents == actual_contents + assert expected_filepath == file_modified + + +def test_pattern_declaration_noop_warning_on_missing_file( + default_tag_format_str: str, + capsys: pytest.CaptureFixture[str], +): + version_replacer = PatternVersionDeclaration.from_string_definition( + "nonexistent_file:__version__", + tag_format=default_tag_format_str, + ) + + file_to_modify = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3", tag_format=default_tag_format_str), + noop=True, + ) + + # Evaluate + assert file_to_modify is None + assert ( + "FILE NOT FOUND: cannot stamp version in non-existent file" + in capsys.readouterr().err + ) + + +def test_pattern_declaration_noop_warning_on_no_version_in_file( + default_tag_format_str: str, + capsys: pytest.CaptureFixture[str], + change_to_ex_proj_dir: None, +): + test_file = "test_file" + starting_contents = """other content\n""" + + # Setup: create file with initial contents + Path(test_file).write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = PatternVersionDeclaration.from_string_definition( + f"{test_file}:__version__:{VersionStampType.NUMBER_FORMAT.value}", + tag_format=default_tag_format_str, + ) + + file_to_modify = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3", tag_format=default_tag_format_str), + noop=True, + ) + + # Evaluate + assert file_to_modify is None + assert ( + "VERSION PATTERN NOT FOUND: no version to stamp in file" + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize( + "search_text, error_msg", + [ + ( + search_text, + error_msg, + ) + for error_msg, search_text in [ + *[ + ("must use 'version' as a named group", s_text) + for s_text in [ + r"^version = (.*)$", + r"^version = (?P.*)", + r"(?P.*)", + ] + ], + ("Invalid regular expression", r"*"), + ] + ], +) +def test_bad_version_regex_fails(search_text: str, error_msg: Pattern[str] | str): + with pytest.raises(ValueError, match=error_msg): + PatternVersionDeclaration( + "doesn't matter", search_text, VersionStampType.NUMBER_FORMAT + ) + + +@pytest.mark.parametrize( + "replacement_def, error_msg", + [ + pytest.param( + replacement_def, + error_msg, + id=str(error_msg), + ) + for replacement_def, error_msg in [ + ( + f"{Path(__file__)!s}", + regexp(r"Invalid replacement definition .*, missing ':'"), + ), + ( + f"{Path(__file__)!s}:__version__:not_a_valid_version_type", + "Invalid stamp type, must be one of:", + ), + ] + ], +) +def test_pattern_declaration_w_invalid_definition( + default_tag_format_str: str, + replacement_def: str, + error_msg: Pattern[str] | str, +): + """ + check if PatternVersionDeclaration raises ValueError when loaded + from invalid strings given in the config file + """ + with pytest.raises(ValueError, match=error_msg): + PatternVersionDeclaration.from_string_definition( + replacement_def, + default_tag_format_str, + ) diff --git a/tests/unit/semantic_release/version/declarations/test_toml_declaration.py b/tests/unit/semantic_release/version/declarations/test_toml_declaration.py new file mode 100644 index 000000000..a768b6cd3 --- /dev/null +++ b/tests/unit/semantic_release/version/declarations/test_toml_declaration.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from pathlib import Path +from re import compile as regexp +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.declarations.toml import TomlVersionDeclaration +from semantic_release.version.version import Version + +from tests.fixtures.git_repo import default_tag_format_str + +if TYPE_CHECKING: + from re import Pattern + + +def test_toml_declaration_is_version_replacer(): + """ + Given the class TomlVersionDeclaration or an instance of it, + When the class is evaluated as a subclass or an instance of, + Then the evaluation is true + """ + assert issubclass(TomlVersionDeclaration, IVersionReplacer) + + toml_instance = TomlVersionDeclaration( + "file", "project.version", VersionStampType.NUMBER_FORMAT + ) + assert isinstance(toml_instance, IVersionReplacer) + + +@pytest.mark.parametrize( + str.join( + ", ", + [ + "replacement_def", + "tag_format", + "starting_contents", + "resulting_contents", + "next_version", + "test_file", + ], + ), + [ + pytest.param( + replacement_def, + tag_format, + starting_contents, + resulting_contents, + next_version, + test_file, + id=test_id, + ) + for test_file in ["test_file.toml"] + for next_version in ["1.2.3"] + for test_id, replacement_def, tag_format, starting_contents, resulting_contents in [ + ( + "Default number format for project.version", + f"{test_file}:project.version", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with single quotes + dedent( + """\ + [project] + version = '1.0.0' + """ + ), + dedent( + f"""\ + [project] + version = "{next_version}" + """ + ), + ), + ( + "Explicit number format for project.version", + f"{test_file}:project.version:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with double quotes + dedent( + """\ + [project] + version = "1.0.0" + """ + ), + dedent( + f"""\ + [project] + version = "{next_version}" + """ + ), + ), + ( + "Using default tag format for toml string variable", + f"{test_file}:version:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses equals separator with single quotes + '''version = "v1.0.0"''', + f'''version = "v{next_version}"''', + ), + ( + "Using custom tag format for toml string variable", + f"{test_file}:version:{VersionStampType.TAG_FORMAT.value}", + "module-v{version}", + # Uses equals separator with double quotes + '''version = "module-v1.0.0"''', + f'''version = "module-v{next_version}"''', + ), + ] + ], +) +def test_toml_declaration_from_definition( + replacement_def: str, + tag_format: str, + starting_contents: str, + resulting_contents: str, + next_version: str, + test_file: str, + change_to_ex_proj_dir: None, +): + """ + Given a file with a formatted version string, + When update_file_w_version() is called with a new version, + Then the file is updated with the new version string in the specified tag or number format + + Version variables can be separated by either "=", ":", "@", or ':=' with optional whitespace + between operator and variable name. The variable name or values can also be wrapped in either + single or double quotes. + """ + # Setup: create file with initial contents + expected_filepath = Path(test_file).resolve() + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = TomlVersionDeclaration.from_string_definition(replacement_def) + + # Act: apply version change + actual_file_modified = version_replacer.update_file_w_version( + new_version=Version.parse(next_version, tag_format=tag_format), + noop=False, + ) + + # Evaluate + actual_contents = Path(test_file).read_text() + assert resulting_contents == actual_contents + assert expected_filepath == actual_file_modified + + +def test_toml_declaration_no_file_change( + change_to_ex_proj_dir: None, +): + """ + Given a configured stamp file is already up-to-date, + When update_file_w_version() is called with the same version, + Then the file is not modified and no path is returned + """ + test_file = "test_file" + next_version = Version.parse("1.2.3") + starting_contents = dedent( + f"""\ + [project] + version = "{next_version}" + """ + ) + + # Setup: create file with initial contents + Path(test_file).write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = TomlVersionDeclaration.from_string_definition( + f"{test_file}:project.version:{VersionStampType.NUMBER_FORMAT.value}", + ) + + # Act: apply version change + file_modified = version_replacer.update_file_w_version( + new_version=next_version, + noop=False, + ) + + # Evaluate + actual_contents = Path(test_file).read_text() + assert starting_contents == actual_contents + assert file_modified is None + + +def test_toml_declaration_error_on_missing_file(): + # Initialization should not fail or do anything intensive + version_replacer = TomlVersionDeclaration.from_string_definition( + "nonexistent_file:version", + ) + + with pytest.raises(FileNotFoundError): + version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=False, + ) + + +def test_toml_declaration_no_version_in_file( + change_to_ex_proj_dir: None, +): + test_file = "test_file" + expected_filepath = Path(test_file).resolve() + starting_contents = dedent( + """\ + [project] + name = "example" + """ + ) + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = TomlVersionDeclaration.from_string_definition( + f"{test_file}:project.version:{VersionStampType.NUMBER_FORMAT.value}", + ) + + file_modified = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=False, + ) + + # Evaluate + actual_contents = expected_filepath.read_text() + assert file_modified is None + assert starting_contents == actual_contents + + +def test_toml_declaration_noop_is_noop( + change_to_ex_proj_dir: None, +): + test_file = "test_file" + expected_filepath = Path(test_file).resolve() + starting_contents = dedent( + """\ + [project] + version = '1.0.0' + """ + ) + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = TomlVersionDeclaration.from_string_definition( + f"{test_file}:project.version:{VersionStampType.NUMBER_FORMAT.value}", + ) + + # Act: apply version change + file_modified = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=True, + ) + + # Evaluate + actual_contents = Path(test_file).read_text() + assert starting_contents == actual_contents + assert expected_filepath == file_modified + + +def test_toml_declaration_noop_warning_on_missing_file( + capsys: pytest.CaptureFixture[str], +): + version_replacer = TomlVersionDeclaration.from_string_definition( + "nonexistent_file:version", + ) + + file_to_modify = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=True, + ) + + # Evaluate + assert file_to_modify is None + assert ( + "FILE NOT FOUND: cannot stamp version in non-existent file" + in capsys.readouterr().err + ) + + +def test_toml_declaration_noop_warning_on_no_version_in_file( + capsys: pytest.CaptureFixture[str], + change_to_ex_proj_dir: None, +): + test_file = "test_file" + starting_contents = dedent( + """\ + [project] + name = "example" + """ + ) + + # Setup: create file with initial contents + Path(test_file).write_text(starting_contents) + + # Create Pattern Replacer + version_replacer = TomlVersionDeclaration.from_string_definition( + f"{test_file}:project.version:{VersionStampType.NUMBER_FORMAT.value}", + ) + + file_to_modify = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=True, + ) + + # Evaluate + assert file_to_modify is None + assert ( + "VERSION PATTERN NOT FOUND: no version to stamp in file" + in capsys.readouterr().err + ) + + +@pytest.mark.parametrize( + "replacement_def, error_msg", + [ + pytest.param( + replacement_def, + error_msg, + id=str(error_msg), + ) + for replacement_def, error_msg in [ + ( + f"{Path(__file__)!s}", + regexp(r"Invalid TOML replacement definition .*, missing ':'"), + ), + ( + f"{Path(__file__)!s}:tool.poetry.version:not_a_valid_version_type", + "Invalid stamp type, must be one of:", + ), + ] + ], +) +def test_toml_declaration_w_invalid_definition( + replacement_def: str, + error_msg: Pattern[str] | str, +): + """ + check if TomlVersionDeclaration raises ValueError when loaded + from invalid strings given in the config file + """ + with pytest.raises(ValueError, match=error_msg): + TomlVersionDeclaration.from_string_definition(replacement_def) diff --git a/tests/unit/semantic_release/version/test_declaration.py b/tests/unit/semantic_release/version/test_declaration.py deleted file mode 100644 index f39d8f3be..000000000 --- a/tests/unit/semantic_release/version/test_declaration.py +++ /dev/null @@ -1,138 +0,0 @@ -import difflib -from pathlib import Path -from textwrap import dedent -from unittest import mock - -import pytest -from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture - -from semantic_release.version.declaration import ( - PatternVersionDeclaration, - TomlVersionDeclaration, -) -from semantic_release.version.version import Version - -from tests.const import EXAMPLE_PROJECT_VERSION -from tests.fixtures.example_project import ( - example_pyproject_toml, - example_setup_cfg, - init_example_project, -) - - -@pytest.mark.usefixtures(init_example_project.__name__) -def test_pyproject_toml_version_found(example_pyproject_toml: Path): - decl = TomlVersionDeclaration( - example_pyproject_toml.resolve(), "tool.poetry.version" - ) - versions = decl.parse() - assert len(versions) == 1 - assert versions.pop() == Version.parse(EXAMPLE_PROJECT_VERSION) - - -@pytest.mark.usefixtures(init_example_project.__name__) -def test_setup_cfg_version_found(example_setup_cfg: Path): - decl = PatternVersionDeclaration( - example_setup_cfg.resolve(), r"^version *= *(?P.*)$" - ) - versions = decl.parse() - assert len(versions) == 1 - assert versions.pop() == Version.parse(EXAMPLE_PROJECT_VERSION) - - -@pytest.mark.parametrize( - "decl_cls, config_file, search_text", - [ - ( - TomlVersionDeclaration, - lazy_fixture(example_pyproject_toml.__name__), - "tool.poetry.version", - ), - ( - PatternVersionDeclaration, - lazy_fixture(example_setup_cfg.__name__), - r"^version = (?P.*)$", - ), - ], -) -@pytest.mark.usefixtures(init_example_project.__name__) -def test_version_replace(decl_cls, config_file, search_text): - new_version = Version(1, 0, 0) - decl = decl_cls(config_file.resolve(), search_text) - orig_content = decl.content - new_content = decl.replace(new_version=new_version) - decl.write(new_content) - - new_decl = decl_cls(config_file.resolve(), search_text) - assert new_decl.parse() == {new_version} - - d = difflib.Differ() - diff = list( - d.compare( - orig_content.splitlines(keepends=True), - new_decl.content.splitlines(keepends=True), - ) - ) - added = [line[2:] for line in diff if line.startswith("+ ")] - removed = [line[2:] for line in diff if line.startswith("- ")] - - assert len(removed) == 1 - assert len(added) == 1 - - (removed_line,) = removed - (added_line,) = added - - # Line is unchanged apart from new version added - assert removed_line.replace(EXAMPLE_PROJECT_VERSION, str(new_version)) == added_line - - -@pytest.mark.parametrize( - "search_text", - [r"^version = (.*)$", r"^version = (?P.*)", r"(?P.*)"], -) -def test_bad_version_regex_fails(search_text): - with mock.patch.object(Path, "exists") as mock_path_exists, pytest.raises( - ValueError, match="must use 'version'" - ): - mock_path_exists.return_value = True - PatternVersionDeclaration("doesn't matter", search_text) - - -def test_pyproject_toml_no_version(tmp_path): - pyproject_toml = tmp_path / "pyproject.toml" - pyproject_toml.write_text( - dedent( - """ - [tool.isort] - profile = "black" - """ - ) - ) - - decl = TomlVersionDeclaration(pyproject_toml.resolve(), "tool.poetry.version") - assert decl.parse() == set() - - -def test_setup_cfg_no_version(tmp_path): - setup_cfg = tmp_path / "setup.cfg" - setup_cfg.write_text( - dedent( - """ - [tool:isort] - profile = black - """ - ) - ) - - decl = PatternVersionDeclaration( - setup_cfg.resolve(), r"^version = (?P.*)$" - ) - assert decl.parse() == set() - - -@pytest.mark.parametrize( - "decl_cls", (TomlVersionDeclaration, PatternVersionDeclaration) -) -def test_version_decl_error_on_missing_file(decl_cls): - with pytest.raises(FileNotFoundError): - decl_cls("/this/is/definitely/a/missing/path/asdfghjkl", "random search text") From 23f69b6ac206d111b1e566367f9b2f033df5c87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedikt=20He=C3=9F?= <85877249+benedikt-hess-km@users.noreply.github.com> Date: Mon, 17 Feb 2025 08:47:40 +0100 Subject: [PATCH 6/7] feat(cmd-version): extend `version_variables` to stamp versions with `@` symbol separator (#1185) Resolves: #1156 * test(declaration): add unit tests of user-defined version stamping patterns * test(cmd-version): add test to demonstrate github actions yaml version tag stamping * docs(configuration): clarify `version_variables` config description & `@` separator usage --------- Co-authored-by: codejedi365 --- docs/configuration.rst | 61 ++++++++++++++-- src/semantic_release/version/declaration.py | 11 +-- .../version/declarations/pattern.py | 6 +- tests/e2e/cmd_version/test_version_stamp.py | 72 +++++++++++++++++++ .../declarations/test_pattern_declaration.py | 20 +++++- 5 files changed, 151 insertions(+), 19 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 7fc4838c4..a94591ff1 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1340,14 +1340,22 @@ to substitute the version number in the file. The replacement algorithm is **ONL pattern match and replace. It will **NOT** evaluate the code nor will PSR understand any internal object structures (ie. ``file:object.version`` will not work). -.. important:: - The Regular Expression expects a version value to exist in the file to be replaced. - It cannot be an empty string or a non-semver compliant string. If this is the very - first time you are using PSR, we recommend you set the version to ``0.0.0``. +The regular expression generated from the ``version_variables`` definition will: - This may become more flexible in the future with resolution of issue `#941`_. +1. Look for the specified ``variable`` name in the ``file``. The variable name can be + enclosed by single (``'``) or double (``"``) quotation marks but they must match. -.. _#941: https://github.com/python-semantic-release/python-semantic-release/issues/941 +2. The variable name defined by ``variable`` and the version must be separated by + an operand symbol (``=``, ``:``, ``:=``, or ``@``). Whitespace is optional around + the symbol. + +3. The value of the variable must match a `SemVer`_ regular expression and can be + enclosed by single (``'``) or double (``"``) quotation marks but they must match. However, + the enclosing quotes of the value do not have to match the quotes surrounding the variable + name. + +4. If the format type is set to ``tf`` then the variable value must have the matching prefix + and suffix of the :ref:`config-tag_format` setting around the `SemVer`_ version number. Given the pattern matching nature of this feature, the Regular Expression is able to support most file formats because of the similarity of variable declaration across @@ -1360,6 +1368,47 @@ regardless of file extension because it looks for a matching pattern string. TOML files as it actually will interpret the TOML file and replace the version number before writing the file back to disk. +This is a comprehensive list (but not all variations) of examples where the following versions +will be matched and replaced by the new version: + +.. code-block:: + + # Common variable declaration formats + version='1.2.3' + version = "1.2.3" + release = "v1.2.3" # if tag_format is set + + # YAML + version: 1.2.3 + + # JSON + "version": "1.2.3" + + # NPM & GitHub Actions YAML + version@1.2.3 + version@v1.2.3 # if tag_format is set + + # Walrus Operator + version := "1.2.3" + + # Excessive whitespace + version = '1.2.3' + + # Mixed Quotes + "version" = '1.2.3' + + # Custom Tag Format with tag_format set (monorepos) + __release__ = "module-v1.2.3" + +.. important:: + The Regular Expression expects a version value to exist in the file to be replaced. + It cannot be an empty string or a non-semver compliant string. If this is the very + first time you are using PSR, we recommend you set the version to ``0.0.0``. + + This may become more flexible in the future with resolution of issue `#941`_. + +.. _#941: https://github.com/python-semantic-release/python-semantic-release/issues/941 + .. warning:: If the file (ex. JSON) you are replacing has two of the same variable name in it, this pattern match will not be able to differentiate between the two and will replace diff --git a/src/semantic_release/version/declaration.py b/src/semantic_release/version/declaration.py index 92da001a1..3c225d1b5 100644 --- a/src/semantic_release/version/declaration.py +++ b/src/semantic_release/version/declaration.py @@ -14,8 +14,6 @@ from semantic_release.version.declarations.toml import TomlVersionDeclaration if TYPE_CHECKING: # pragma: no cover - from typing import Any - from semantic_release.version.version import Version @@ -66,13 +64,8 @@ def content(self) -> str: self._content = self.path.read_text() return self._content - # mypy doesn't like properties? - @content.setter # type: ignore[attr-defined] - def _(self, _: Any) -> None: - raise AttributeError("'content' cannot be set directly") - - @content.deleter # type: ignore[attr-defined] - def _(self) -> None: + @content.deleter + def content(self) -> None: log.debug("resetting instance-stored source file contents") self._content = None diff --git a/src/semantic_release/version/declarations/pattern.py b/src/semantic_release/version/declarations/pattern.py index 73f67b465..55873ce0a 100644 --- a/src/semantic_release/version/declarations/pattern.py +++ b/src/semantic_release/version/declarations/pattern.py @@ -230,9 +230,9 @@ def from_string_definition( # Supports optional matching quotations around variable name # Negative lookbehind to ensure we don't match part of a variable name f"""(?x)(?P['"])?(?['"])?{value_replace_pattern_str}(?P=quote2)?""", ], diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index c63d5b66c..9d45b6019 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -373,6 +373,78 @@ def test_stamp_version_variables_json( assert orig_json == resulting_json_obj +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_variables_yaml_github_actions( + cli_runner: CliRunner, + update_pyproject_toml: UpdatePyprojectTomlFn, + default_tag_format_str: str, +) -> None: + """ + Given a yaml file with github actions 'uses:' directives which use @vX.Y.Z version declarations, + When a version is stamped and configured to stamp the version using the tag format, + Then the file is updated with the new version in the tag format + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/1156 + """ + orig_version = "0.0.0" + new_version = "0.1.0" + target_file = Path("combined.yml") + action1_yaml_filepath = "my-org/my-actions/.github/workflows/action1.yml" + action2_yaml_filepath = "my-org/my-actions/.github/workflows/action2.yml" + orig_yaml = dedent( + f"""\ + --- + on: + workflow_call: + + jobs: + action1: + uses: {action1_yaml_filepath}@{default_tag_format_str.format(version=orig_version)} + action2: + uses: {action2_yaml_filepath}@{default_tag_format_str.format(version=orig_version)} + """ + ) + expected_action1_value = ( + f"{action1_yaml_filepath}@{default_tag_format_str.format(version=new_version)}" + ) + expected_action2_value = ( + f"{action2_yaml_filepath}@{default_tag_format_str.format(version=new_version)}" + ) + + # Setup: Write initial text in file + target_file.write_text(orig_yaml) + + # Setup: Set configuration to modify the yaml file + update_pyproject_toml( + "tool.semantic_release.version_variables", + [ + f"{target_file}:{action1_yaml_filepath}:{VersionStampType.TAG_FORMAT.value}", + f"{target_file}:{action2_yaml_filepath}:{VersionStampType.TAG_FORMAT.value}", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = cli_runner.invoke(main, cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + resulting_yaml_obj = yaml.safe_load(target_file.read_text()) + + # Check the version was updated + assert expected_action1_value == resulting_yaml_obj["jobs"]["action1"]["uses"] + assert expected_action2_value == resulting_yaml_obj["jobs"]["action2"]["uses"] + + # Check the rest of the content is the same (by setting the version & comparing) + original_yaml_obj = yaml.safe_load(orig_yaml) + original_yaml_obj["jobs"]["action1"]["uses"] = expected_action1_value + original_yaml_obj["jobs"]["action2"]["uses"] = expected_action2_value + + assert original_yaml_obj == resulting_yaml_obj + + @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml_kustomization_container_spec( cli_runner: CliRunner, diff --git a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py index b49f87fa0..fd7cb7dad 100644 --- a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -92,6 +92,24 @@ def test_pattern_declaration_is_version_replacer(): '''__version__ = "module-v1.0.0"''', f'''__version__ = "module-v{next_version}"''', ), + ( + # Based on https://github.com/python-semantic-release/python-semantic-release/issues/1156 + "Using default tag format for github actions uses-directive", + f"{test_file}:repo/action-name:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses @ symbol separator without quotes or spaces + """ uses: repo/action-name@v1.0.0""", + f""" uses: repo/action-name@v{next_version}""", + ), + ( + # Based on https://github.com/python-semantic-release/python-semantic-release/issues/1156 + "Using custom tag format for github actions uses-directive", + f"{test_file}:repo/action-name:{VersionStampType.TAG_FORMAT.value}", + "module-v{version}", + # Uses @ symbol separator without quotes or spaces + """ uses: repo/action-name@module-v1.0.0""", + f""" uses: repo/action-name@module-v{next_version}""", + ), ( # Based on https://github.com/python-semantic-release/python-semantic-release/issues/846 "Using default tag format for multi-line yaml", @@ -205,7 +223,7 @@ def test_pattern_declaration_from_definition( When update_file_w_version() is called with a new version, Then the file is updated with the new version string in the specified tag or number format - Version variables can be separated by either "=", ":", or ':=' with optional whitespace + Version variables can be separated by either "=", ":", "@", or ':=' with optional whitespace between operator and variable name. The variable name or values can also be wrapped in either single or double quotes. """ From 3b7466302c07c543377ec0c79bf178291d51f7ca Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 17 Feb 2025 07:56:42 +0000 Subject: [PATCH 7/7] 9.20.0 Automatically generated by python-semantic-release --- CHANGELOG.rst | 36 ++++++++++++++++++++++ docs/automatic-releases/github-actions.rst | 14 ++++----- docs/configuration.rst | 10 +++--- pyproject.toml | 2 +- src/semantic_release/__init__.py | 2 +- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db8c86e47..2e21e4312 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,42 @@ CHANGELOG ========= +.. _changelog-v9.20.0: + +v9.20.0 (2025-02-17) +==================== + +✨ Features +----------- + +* **cmd-version**: Enable stamping of tag formatted versions into files, closes `#846`_ (`PR#1190`_, + `8906d8e`_) + +* **cmd-version**: Extend ``version_variables`` to stamp versions with ``@`` symbol separator, + closes `#1156`_ (`PR#1185`_, `23f69b6`_) + +📖 Documentation +---------------- + +* **configuration**: Add usage information for tag format version stamping (`PR#1190`_, `8906d8e`_) + +* **configuration**: Clarify ``version_variables`` config description & ``@`` separator usage + (`PR#1185`_, `23f69b6`_) + +⚙️ Build System +---------------- + +* **deps**: Add ``deprecated~=1.2`` for deprecation notices & sphinx documentation (`PR#1190`_, + `8906d8e`_) + +.. _#1156: https://github.com/python-semantic-release/python-semantic-release/issues/1156 +.. _#846: https://github.com/python-semantic-release/python-semantic-release/issues/846 +.. _23f69b6: https://github.com/python-semantic-release/python-semantic-release/commit/23f69b6ac206d111b1e566367f9b2f033df5c87a +.. _8906d8e: https://github.com/python-semantic-release/python-semantic-release/commit/8906d8e70467af1489d797ec8cb09b1f95e5d409 +.. _PR#1185: https://github.com/python-semantic-release/python-semantic-release/pull/1185 +.. _PR#1190: https://github.com/python-semantic-release/python-semantic-release/pull/1190 + + .. _changelog-v9.19.1: v9.19.1 (2025-02-11) diff --git a/docs/automatic-releases/github-actions.rst b/docs/automatic-releases/github-actions.rst index b192eb6f9..6d80189c7 100644 --- a/docs/automatic-releases/github-actions.rst +++ b/docs/automatic-releases/github-actions.rst @@ -337,7 +337,7 @@ before the :ref:`version ` subcommand. .. code:: yaml - - uses: python-semantic-release/python-semantic-release@v9.19.1 + - uses: python-semantic-release/python-semantic-release@v9.20.0 with: root_options: "-vv --noop" @@ -576,7 +576,7 @@ before the :ref:`publish ` subcommand. .. code:: yaml - - uses: python-semantic-release/publish-action@v9.19.1 + - uses: python-semantic-release/publish-action@v9.20.0 with: root_options: "-vv --noop" @@ -684,7 +684,7 @@ to the GitHub Release Assets as well. - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v9.19.1 + uses: python-semantic-release/python-semantic-release@v9.20.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" @@ -695,7 +695,7 @@ to the GitHub Release Assets as well. if: steps.release.outputs.released == 'true' - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.19.1 + uses: python-semantic-release/publish-action@v9.20.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -744,7 +744,7 @@ The equivalent GitHub Action configuration would be: - name: Action | Semantic Version Release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v9.19.1 + uses: python-semantic-release/python-semantic-release@v9.20.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch @@ -772,13 +772,13 @@ Publish Action. .. code:: yaml - name: Release Project 1 - uses: python-semantic-release/python-semantic-release@v9.19.1 + uses: python-semantic-release/python-semantic-release@v9.20.0 with: directory: ./project1 github_token: ${{ secrets.GITHUB_TOKEN }} - name: Release Project 2 - uses: python-semantic-release/python-semantic-release@v9.19.1 + uses: python-semantic-release/python-semantic-release@v9.20.0 with: directory: ./project2 github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/configuration.rst b/docs/configuration.rst index a94591ff1..78e20d183 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1209,9 +1209,9 @@ within the TOML file, which is more accurate than a pattern replace. The ``version_toml`` option is commonly used to update the version number in the project definition file: ``pyproject.toml`` as seen in the example below. -As of ${NEW_RELEASE_TAG}, the ``version_toml`` option accepts a colon-separated definition +As of v9.20.0, the ``version_toml`` option accepts a colon-separated definition with either 2 or 3 parts. The 2-part definition includes the file path and the version -parameter (in dot-notation). Newly with ${NEW_RELEASE_TAG}, it also accepts an optional +parameter (in dot-notation). Newly with v9.20.0, it also accepts an optional 3rd part to allow configuration of the format type. **Available Format Types** @@ -1271,9 +1271,9 @@ The ``version_variables`` configuration option is a list of string definitions that defines where the version number should be updated in the repository, when a new version is released. -As of ${NEW_RELEASE_TAG}, the ``version_variables`` option accepts a +As of v9.20.0, the ``version_variables`` option accepts a colon-separated definition with either 2 or 3 parts. The 2-part definition includes -the file path and the variable name. Newly with ${NEW_RELEASE_TAG}, it also accepts +the file path and the variable name. Newly with v9.20.0, it also accepts an optional 3rd part to allow configuration of the format type. **Available Format Types** @@ -1283,7 +1283,7 @@ an optional 3rd part to allow configuration of the format type. If the format type is not specified, it will default to the number format. -Prior to ${NEW_RELEASE_TAG}, PSR only supports entries with the first 2-parts +Prior to v9.20.0, PSR only supports entries with the first 2-parts as the tag format type was not available and would only replace numeric version numbers. diff --git a/pyproject.toml b/pyproject.toml index b11f2d7f7..35f8a8d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "9.19.1" +version = "9.20.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 cad1767ec..dd5648aaa 100644 --- a/src/semantic_release/__init__.py +++ b/src/semantic_release/__init__.py @@ -24,7 +24,7 @@ tags_and_versions, ) -__version__ = "9.19.1" +__version__ = "9.20.0" __all__ = [ "CommitParser",