From 544e7469f30164a781f4980f1e834d8ef97dd65b Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 14 Dec 2025 17:21:28 -0700 Subject: [PATCH 1/6] ci(deps): bump `python-semantic-release@v10.5.2` action to `v10.5.3` (#1396) --- .github/workflows/cicd.yml | 2 +- .github/workflows/validate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index b1696d9b6..6222c9e2a 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -142,7 +142,7 @@ jobs: - name: Release | Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} verbosity: 1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0e1e7654c..4ee55db53 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -112,7 +112,7 @@ jobs: - name: Build | Build next version artifacts id: version - uses: python-semantic-release/python-semantic-release@02f2a5c74dbb6aa2989f10fc4af12cd8e6bf025f # v10.5.2 + uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 with: github_token: "" verbosity: 1 From 24b91f4883dc3605b9b5105999cb016372eb8091 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:48:57 +0000 Subject: [PATCH 2/6] ci(deps): bump `python-semantic-release/publish-action@v10.5.2` to `v10.5.3` (#1396) --- .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 6222c9e2a..49d5f7cf4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -149,7 +149,7 @@ jobs: build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@948bb8fccc5e8072f2c52464b45c76a8bb3878e6 # v10.5.2 + uses: python-semantic-release/publish-action@310a9983a0ae878b29f3aac778d7c77c1db27378 # v10.5.3 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} From 2833aa943a6a016b8208146a89f7b4ec0efa6cd0 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 4 Jan 2026 11:13:05 -0700 Subject: [PATCH 3/6] feat(parser-emoji): adds more non-release triggering emojis to the default emoji parser (#1410) * test(parser-emoji): add unit tests for no-release triggering emoji commits --- src/semantic_release/commit_parser/emoji.py | 7 ++++++- tests/const.py | 2 +- .../commit_parser/test_emoji.py | 18 +++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index 801160208..e2fb5ae30 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -65,7 +65,12 @@ class EmojiParserOptions(ParserOptions): ) """Commit-type prefixes that should result in a patch release bump.""" - other_allowed_tags: Tuple[str, ...] = (":memo:", ":checkmark:") + other_allowed_tags: Tuple[str, ...] = ( + ":checkmark:", + ":construction_worker:", + ":memo:", + ":recycle:", + ) """Commit-type prefixes that are allowed but do not result in a version bump.""" allowed_tags: Tuple[str, ...] = ( diff --git a/tests/const.py b/tests/const.py index 69a7ca778..8ff979f2f 100644 --- a/tests/const.py +++ b/tests/const.py @@ -93,7 +93,7 @@ class RepoActionStep(str, Enum): ) EMOJI_COMMITS_MINOR = ( *EMOJI_COMMITS_PATCH, - ":sparkles::pencil: docs for something special\n", + ":sparkles::memo: docs for something special\n", # Emoji in description should not be used to evaluate change type ":sparkles: last minute rush order\n\nGood thing we're 10x developers :boom:\n", ) diff --git a/tests/unit/semantic_release/commit_parser/test_emoji.py b/tests/unit/semantic_release/commit_parser/test_emoji.py index c477579ec..4e2dcd597 100644 --- a/tests/unit/semantic_release/commit_parser/test_emoji.py +++ b/tests/unit/semantic_release/commit_parser/test_emoji.py @@ -42,20 +42,28 @@ [":bug: Fixing a bug", "The bug is finally gone!"], [], ), - # No release + # No release with specified emoji ( - ":pencil: Documentation changes", + ":memo: Documentation changes", + LevelBump.NO_RELEASE, + ":memo:", + [":memo: Documentation changes"], + [], + ), + # No release with random emoji + ( + ":construction: Work in progress", LevelBump.NO_RELEASE, "Other", - [":pencil: Documentation changes"], + [":construction: Work in progress"], [], ), # Multiple emojis ( - ":sparkles::pencil: Add a feature and document it", + ":sparkles::memo: Add a feature and document it", LevelBump.MINOR, ":sparkles:", - [":sparkles::pencil: Add a feature and document it"], + [":sparkles::memo: Add a feature and document it"], [], ), # Emoji in description From 81a0f98b0f36b65df5039ab335760295322bfd9c Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 4 Jan 2026 22:01:07 -0700 Subject: [PATCH 4/6] fix(cmd-publish): fix handling of asset uploading errors on publish (#1397) Resolves: #1395 * fix(github): fix bubble up errors of asset uploads for GitHub * test(cmd-publish): add e2e test for handling GitHub authentication errors on upload * test(github): add comprehensive error tests for `upload_dists()` --- src/semantic_release/cli/commands/publish.py | 17 +- src/semantic_release/hvcs/github.py | 13 +- tests/e2e/cmd_publish/test_publish.py | 85 ++++++- .../unit/semantic_release/hvcs/test_github.py | 223 +++++++++++++++++- 4 files changed, 328 insertions(+), 10 deletions(-) diff --git a/src/semantic_release/cli/commands/publish.py b/src/semantic_release/cli/commands/publish.py index 4efab72de..afcd24d2f 100644 --- a/src/semantic_release/cli/commands/publish.py +++ b/src/semantic_release/cli/commands/publish.py @@ -6,6 +6,7 @@ from git import Repo from semantic_release.cli.util import noop_report +from semantic_release.errors import AssetUploadError from semantic_release.globals import logger from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.version.algorithm import tags_and_versions @@ -90,9 +91,13 @@ def publish(cli_ctx: CliContextObj, tag: str) -> None: ) return - publish_distributions( - tag=tag, - hvcs_client=hvcs_client, - dist_glob_patterns=dist_glob_patterns, - noop=runtime.global_cli_options.noop, - ) + try: + publish_distributions( + tag=tag, + hvcs_client=hvcs_client, + dist_glob_patterns=dist_glob_patterns, + noop=runtime.global_cli_options.noop, + ) + except AssetUploadError as err: + click.echo(err, err=True) + ctx.exit(1) diff --git a/src/semantic_release/hvcs/github.py b/src/semantic_release/hvcs/github.py index 5ccc9004b..b2c9e5be4 100644 --- a/src/semantic_release/hvcs/github.py +++ b/src/semantic_release/hvcs/github.py @@ -463,14 +463,25 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: # Upload assets n_succeeded = 0 + errors = [] for file_path in ( f for f in glob.glob(dist_glob, recursive=True) if os.path.isfile(f) ): try: self.upload_release_asset(release_id, file_path) n_succeeded += 1 - except HTTPError: # noqa: PERF203 + except HTTPError as err: # noqa: PERF203 logger.exception("error uploading asset %s", file_path) + status_code = ( + err.response.status_code if err.response is not None else "unknown" + ) + error_msg = f"Failed to upload asset '{file_path}' to release" + if status_code != "unknown": + error_msg += f" (HTTP {status_code})" + errors.append(error_msg) + + if errors: + raise AssetUploadError("\n".join(errors)) return n_succeeded diff --git a/tests/e2e/cmd_publish/test_publish.py b/tests/e2e/cmd_publish/test_publish.py index 3b4fca2bf..23eb89bb0 100644 --- a/tests/e2e/cmd_publish/test_publish.py +++ b/tests/e2e/cmd_publish/test_publish.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, cast from unittest import mock import pytest @@ -15,8 +16,15 @@ if TYPE_CHECKING: from typing import Sequence + from requests_mock import Mocker + from tests.conftest import RunCliFn - from tests.fixtures.git_repo import BuiltRepoResult, GetVersionsFromRepoBuildDefFn + from tests.fixtures.git_repo import ( + BuiltRepoResult, + GetCfgValueFromDefFn, + GetHvcsClientFromRepoDefFn, + GetVersionsFromRepoBuildDefFn, + ) @pytest.mark.parametrize("cmd_args", [(), ("--tag", "latest")]) @@ -87,3 +95,76 @@ def test_publish_fails_on_nonexistant_tag(run_cli: RunCliFn): f"Tag '{non_existant_tag}' not found in local repository!" in result.stderr ) mocked_upload_dists.assert_not_called() + + +@pytest.mark.parametrize( + "repo_result", + [ + lazy_fixture(repo_fixture_name) + for repo_fixture_name in [ + repo_w_trunk_only_conventional_commits.__name__, + ] + ], +) +def test_publish_fails_on_github_upload_dists( + repo_result: BuiltRepoResult, + get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + run_cli: RunCliFn, + requests_mock: Mocker, +): + """ + Given a repo with conventional commits and at least one tag + When publishing to a valid tag but upload dists authentication fails + Then the command fails with exit code 1 + + Reference: python-semantic-release/publish-action#77 + """ + repo_def = repo_result["definition"] + tag_format_str = cast("str", get_cfg_value_from_def(repo_def, "tag_format_str")) + all_versions = get_versions_from_repo_build_def(repo_def) + latest_release_version = all_versions[-1] + release_tag = tag_format_str.format(version=latest_release_version) + hvcs_client = get_hvcs_client_from_repo_def(repo_def) + if not isinstance(hvcs_client, Github): + pytest.fail("Test setup error: HvcsClient is not a Github instance") + + release_id = 12 + files = [ + Path(f"dist/package-{latest_release_version}.whl"), + Path(f"dist/package-{latest_release_version}.tar.gz"), + ] + tag_endpoint = hvcs_client.create_api_url( + endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}/releases/tags/{release_tag}", + ) + release_endpoint = hvcs_client.create_api_url( + endpoint=f"/repos/{hvcs_client.owner}/{hvcs_client.repo_name}/releases/{release_id}" + ) + upload_url = release_endpoint + "/assets" + expected_num_upload_attempts = len(files) + + # Setup: Create distribution files before upload + for file in files: + file.parent.mkdir(parents=True, exist_ok=True) + file.touch() + + # Setup: Mock upload url retrieval + requests_mock.register_uri("GET", tag_endpoint, json={"id": release_id}) + requests_mock.register_uri( + "GET", release_endpoint, json={"upload_url": f"{upload_url}{{?name,label}}"} + ) + + # Setup: Mock upload failure + uploader_mock = requests_mock.register_uri("POST", upload_url, status_code=403) + + # Act + cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, "--tag", "latest"] + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_exit_code(1, result, cli_cmd) + assert isinstance(result.exception, SystemExit) + assert expected_num_upload_attempts == uploader_mock.call_count + for file in files: + assert f"Failed to upload asset '{file}'" in result.stderr diff --git a/tests/unit/semantic_release/hvcs/test_github.py b/tests/unit/semantic_release/hvcs/test_github.py index e7f69a5ea..48f0564f5 100644 --- a/tests/unit/semantic_release/hvcs/test_github.py +++ b/tests/unit/semantic_release/hvcs/test_github.py @@ -13,6 +13,7 @@ from requests import HTTPError, Response, Session from requests.auth import _basic_auth_str +from semantic_release.errors import AssetUploadError from semantic_release.hvcs.github import Github from semantic_release.hvcs.token_auth import TokenAuth @@ -1026,7 +1027,7 @@ def test_upload_release_asset_fails( # Note - mocking as the logic for uploading an asset # is covered by testing above, no point re-testing. -def test_upload_dists_when_release_id_not_found(default_gh_client): +def test_upload_dists_when_release_id_not_found(default_gh_client: Github): tag = "v1.0.0" path = "doesn't matter" expected_num_uploads = 0 @@ -1093,3 +1094,223 @@ def test_upload_dists_when_release_id_found( assert expected_num_uploads == num_uploads mock_get_release_id_by_tag.assert_called_once_with(tag=tag) assert expected_files_uploaded == mock_upload_release_asset.call_args_list + + +@pytest.mark.parametrize( + "status_code, error_message", + [ + (401, "Unauthorized"), + (403, "Forbidden"), + (400, "Bad Request"), + (404, "Not Found"), + (429, "Too Many Requests"), + (500, "Internal Server Error"), + (503, "Service Unavailable"), + ], +) +def test_upload_dists_fails_with_http_error( + default_gh_client: Github, + status_code: int, + error_message: str, +): + """Given a release exists, when upload_release_asset raises HTTPError, then AssetUploadError is raised.""" + # Setup + release_id = 123 + tag = "v1.0.0" + files = ["dist/package-1.0.0.whl", "dist/package-1.0.0.tar.gz"] + glob_pattern = "dist/*" + expected_num_upload_attempts = len(files) + + # Create mock HTTPError with proper response + http_error = HTTPError(error_message) + http_error.response = Response() + http_error.response.status_code = status_code + http_error.response._content = error_message.encode() + + # Skip filesystem checks + mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True) + mocked_globber = mock.patch.object(glob, "glob", return_value=files) + + # Set up mock environment + with mocked_globber, mocked_isfile, mock.patch.object( + default_gh_client, + default_gh_client.get_release_id_by_tag.__name__, + return_value=release_id, + ) as mock_get_release_id_by_tag, mock.patch.object( + default_gh_client, + default_gh_client.upload_release_asset.__name__, + side_effect=http_error, + ) as mock_upload_release_asset: + # Execute method under test expecting an exception to be raised + with pytest.raises(AssetUploadError) as exc_info: + default_gh_client.upload_dists(tag, glob_pattern) + + # Evaluate (expected -> actual) + mock_get_release_id_by_tag.assert_called_once_with(tag=tag) + + # Should have attempted to upload all files even though they fail + assert expected_num_upload_attempts == mock_upload_release_asset.call_count + + # Verify the error message contains useful information about failed uploads + error_msg = str(exc_info.value) + + # Each file should be mentioned in the error message with status code + for file in files: + assert f"Failed to upload asset '{file}'" in error_msg + assert f"(HTTP {status_code})" in error_msg + + +def test_upload_dists_fails_authentication_error_401(default_gh_client: Github): + """Given a release exists, when upload fails with 401, then AssetUploadError is raised with auth context.""" + # Setup + release_id = 456 + tag = "v2.0.0" + files = ["dist/package-2.0.0.whl"] + glob_pattern = "dist/*.whl" + + # Create mock HTTPError for authentication failure + http_error = HTTPError("401 Client Error: Unauthorized") + http_error.response = Response() + http_error.response.status_code = 401 + http_error.response._content = b'{"message": "Bad credentials"}' + + # Skip filesystem checks + mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True) + mocked_globber = mock.patch.object(glob, "glob", return_value=files) + + # Set up mock environment + with mocked_globber, mocked_isfile, mock.patch.object( + default_gh_client, + default_gh_client.get_release_id_by_tag.__name__, + return_value=release_id, + ), mock.patch.object( + default_gh_client, + default_gh_client.upload_release_asset.__name__, + side_effect=http_error, + ): + # Execute method under test expecting an exception to be raised + with pytest.raises(AssetUploadError) as exc_info: + default_gh_client.upload_dists(tag, glob_pattern) + + # Verify the error message contains file, release information and status code + error_msg = str(exc_info.value) + assert "Failed to upload asset" in error_msg + assert files[0] in error_msg + assert "(HTTP 401)" in error_msg + + +def test_upload_dists_fails_forbidden_error_403(default_gh_client: Github): + """Given a release exists, when upload fails with 403, then AssetUploadError is raised with permission context.""" + # Setup + release_id = 789 + tag = "v3.0.0" + files = ["dist/package-3.0.0.tar.gz"] + glob_pattern = "dist/*.tar.gz" + + # Create mock HTTPError for forbidden access + http_error = HTTPError("403 Client Error: Forbidden") + http_error.response = Response() + http_error.response.status_code = 403 + + # Skip filesystem checks + mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True) + mocked_globber = mock.patch.object(glob, "glob", return_value=files) + + # Set up mock environment + with mocked_globber, mocked_isfile, mock.patch.object( + default_gh_client, + default_gh_client.get_release_id_by_tag.__name__, + return_value=release_id, + ), mock.patch.object( + default_gh_client, + default_gh_client.upload_release_asset.__name__, + side_effect=http_error, + ): + # Execute method under test expecting an exception to be raised + with pytest.raises(AssetUploadError) as exc_info: + default_gh_client.upload_dists(tag, glob_pattern) + + # Verify the error message contains file, release information and status code + error_msg = str(exc_info.value) + assert "Failed to upload asset" in error_msg + assert f"Failed to upload asset '{files[0]}'" in error_msg + assert "(HTTP 403)" in error_msg + + +def test_upload_dists_partial_failure(default_gh_client: Github): + """Given multiple files to upload, when some succeed and some fail, then AssetUploadError is raised.""" + # Setup + release_id = 999 + tag = "v4.0.0" + files = [ + "dist/package-4.0.0.whl", + "dist/package-4.0.0.tar.gz", + "dist/package-4.0.0-py3-none-any.whl", + ] + glob_pattern = "dist/*" + expected_num_upload_attempts = len(files) + + # Create mock HTTPError for the second file + http_error = HTTPError("500 Server Error: Internal Server Error") + http_error.response = Response() + http_error.response.status_code = 500 + + # Skip filesystem checks + mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True) + mocked_globber = mock.patch.object(glob, "glob", return_value=files) + + # Set up mock environment - first upload succeeds, second fails, third fails + upload_results = [True, http_error, http_error] + + with mocked_globber, mocked_isfile, mock.patch.object( + default_gh_client, + default_gh_client.get_release_id_by_tag.__name__, + return_value=release_id, + ), mock.patch.object( + default_gh_client, + default_gh_client.upload_release_asset.__name__, + side_effect=upload_results, + ) as mock_upload_release_asset: + # Execute method under test expecting an exception to be raised + with pytest.raises(AssetUploadError) as exc_info: + default_gh_client.upload_dists(tag, glob_pattern) + + # Verify all uploads were attempted + assert expected_num_upload_attempts == mock_upload_release_asset.call_count + + # Verify the error message mentions the failed files with status code + error_msg = str(exc_info.value) + assert f"Failed to upload asset '{files[1]}'" in error_msg + assert f"Failed to upload asset '{files[2]}'" in error_msg + assert "(HTTP 500)" in error_msg + + +def test_upload_dists_all_succeed(default_gh_client: Github): + """Given multiple files to upload, when all succeed, then return count of successful uploads.""" + # Setup + release_id = 111 + tag = "v5.0.0" + files = ["dist/package-5.0.0.whl", "dist/package-5.0.0.tar.gz"] + glob_pattern = "dist/*" + expected_num_uploads = len(files) + + # Skip filesystem checks + mocked_isfile = mock.patch.object(os.path, "isfile", return_value=True) + mocked_globber = mock.patch.object(glob, "glob", return_value=files) + + # Set up mock environment - all uploads succeed + with mocked_globber, mocked_isfile, mock.patch.object( + default_gh_client, + default_gh_client.get_release_id_by_tag.__name__, + return_value=release_id, + ), mock.patch.object( + default_gh_client, + default_gh_client.upload_release_asset.__name__, + return_value=True, + ) as mock_upload_release_asset: + # Execute method under test + num_uploads = default_gh_client.upload_dists(tag, glob_pattern) + + # Evaluate (expected -> actual) + assert expected_num_uploads == num_uploads + assert expected_num_uploads == mock_upload_release_asset.call_count From 03431947833b4e3f3fc79b09fc626e0f30508a2b Mon Sep 17 00:00:00 2001 From: lilfetz22 <50301466+lilfetz22@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:03:41 -0500 Subject: [PATCH 5/6] fix(cmd-config-generate): fix config output for Microsoft Windows UTF-8 encoding (#1400) Resolves: #702 * docs(cmd-config-generate): add Windows PowerShell specific `generate-config` usage example * test(cmd-config-generate): adds UTF-8 encoding test for platform specific output --- docs/api/commands.rst | 32 ++++++-- .../cli/commands/generate_config.py | 35 +++++++-- src/semantic_release/cli/util.py | 2 +- tests/const.py | 2 +- tests/e2e/cmd_config/test_generate_config.py | 78 +++++++++++++++++++ 5 files changed, 133 insertions(+), 16 deletions(-) diff --git a/docs/api/commands.rst b/docs/api/commands.rst index cdf9be45c..dd31a4e1a 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -473,16 +473,36 @@ Release corresponding to this version. Generate default configuration for semantic-release, to help you get started quickly. You can inspect the defaults, write to a file and then edit according to -your needs. -For example, to append the default configuration to your pyproject.toml -file, you can use the following command:: +your needs. For example, to append the default configuration to your ``pyproject.toml`` +file, you can use the following command (in POSIX-Compliant shells): - $ semantic-release generate-config -f toml --pyproject >> pyproject.toml +.. code-block:: bash + + semantic-release generate-config --pyproject >> pyproject.toml + +On Windows PowerShell, the redirection operators (`>`/`>>`) default to UTF-16LE, +which can introduce NUL characters. Prefer one of the following to keep UTF-8: + +.. code-block:: console + + # 2 File output Piping Options in PowerShell (Out-File or Set-Content) + + # Example for writing to pyproject.toml using Out-File: + semantic-release generate-config --pyproject | Out-File -Encoding utf8 pyproject.toml + + # Example for writing to a releaserc.toml file using Set-Content: + semantic-release generate-config -f toml | Set-Content -Encoding utf8 releaserc.toml If your project doesn't already leverage TOML files for configuration, it might better -suit your project to use JSON instead:: +suit your project to use JSON instead: + +.. code-block:: bash + + # POSIX-Compliant shell example + semantic-release generate-config -f json | tee releaserc.json - $ semantic-release generate-config -f json + # Windows PowerShell example + semantic-release generate-config -f json | Out-File -Encoding utf8 releaserc.json If you would like to add JSON configuration to a shared file, e.g. ``package.json``, you can then simply add the output from this command as a **top-level** key to the file. diff --git a/src/semantic_release/cli/commands/generate_config.py b/src/semantic_release/cli/commands/generate_config.py index a6bf36013..7d498b31e 100644 --- a/src/semantic_release/cli/commands/generate_config.py +++ b/src/semantic_release/cli/commands/generate_config.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import sys +from typing import Literal import click import tomlkit @@ -31,7 +33,9 @@ "'semantic_release'" ), ) -def generate_config(fmt: str = "toml", is_pyproject_toml: bool = False) -> None: +def generate_config( + fmt: Literal["toml", "json"], is_pyproject_toml: bool = False +) -> None: """ Generate default configuration for semantic-release, to help you get started quickly. You can inspect the defaults, write to a file and then edit according to @@ -42,14 +46,29 @@ def generate_config(fmt: str = "toml", is_pyproject_toml: bool = False) -> None: """ # due to possible IntEnum values (which are not supported by tomlkit.dumps, see sdispater/tomlkit#237), # we must ensure the transformation of the model to a dict uses json serializable values - config = RawConfig().model_dump(mode="json", exclude_none=True) + config_dct = { + "semantic_release": RawConfig().model_dump(mode="json", exclude_none=True) + } - config_dct = {"semantic_release": config} - if is_pyproject_toml and fmt == "toml": - config_dct = {"tool": config_dct} + if is_pyproject_toml: + output = tomlkit.dumps({"tool": config_dct}) - if fmt == "toml": - click.echo(tomlkit.dumps(config_dct)) + elif fmt == "toml": + output = tomlkit.dumps(config_dct) elif fmt == "json": - click.echo(json.dumps(config_dct, indent=4)) + output = json.dumps(config_dct, indent=4) + + else: + raise ValueError(f"Unsupported format: {fmt}") + + # Write output directly to stdout buffer as UTF-8 bytes + # This ensures consistent UTF-8 output on all platforms, especially Windows where + # shell redirection (>, >>) defaults to the system encoding (e.g., UTF-16LE or cp1252) + # By writing to sys.stdout.buffer, we bypass the encoding layer and guarantee UTF-8. + try: + sys.stdout.buffer.write(f"{output.strip()}\n".encode("utf-8")) # noqa: UP012; allow explicit encoding declaration + sys.stdout.buffer.flush() + except (AttributeError, TypeError): + # Fallback for environments without buffer (shouldn't happen in standard Python) + click.echo(output) diff --git a/src/semantic_release/cli/util.py b/src/semantic_release/cli/util.py index 37d249c1a..4696a7270 100644 --- a/src/semantic_release/cli/util.py +++ b/src/semantic_release/cli/util.py @@ -75,7 +75,7 @@ def load_raw_config_file(config_file: Path | str) -> dict[Any, Any]: while trying to read the specified configuration file """ logger.info("Loading configuration from %s", config_file) - raw_text = (Path() / config_file).resolve().read_text(encoding="utf-8") + raw_text = (Path() / config_file).resolve().read_text(encoding="utf-8-sig") try: logger.debug("Trying to parse configuration %s in TOML format", config_file) return parse_toml(raw_text) diff --git a/tests/const.py b/tests/const.py index 8ff979f2f..186a23013 100644 --- a/tests/const.py +++ b/tests/const.py @@ -39,7 +39,7 @@ class RepoActionStep(str, Enum): SUCCESS_EXIT_CODE = 0 CHANGELOG_SUBCMD = Cli.SubCmds.CHANGELOG.name.lower() -GENERATE_CONFIG_SUBCMD = Cli.SubCmds.GENERATE_CONFIG.name.lower() +GENERATE_CONFIG_SUBCMD = Cli.SubCmds.GENERATE_CONFIG.name.lower().replace("_", "-") PUBLISH_SUBCMD = Cli.SubCmds.PUBLISH.name.lower() VERSION_SUBCMD = Cli.SubCmds.VERSION.name.lower() diff --git a/tests/e2e/cmd_config/test_generate_config.py b/tests/e2e/cmd_config/test_generate_config.py index 4a21f0be7..a9db934ea 100644 --- a/tests/e2e/cmd_config/test_generate_config.py +++ b/tests/e2e/cmd_config/test_generate_config.py @@ -1,11 +1,15 @@ from __future__ import annotations import json +import subprocess +import sys +from sys import executable as python_interpreter from typing import TYPE_CHECKING import pytest import tomlkit +import semantic_release from semantic_release.cli.config import RawConfig from tests.const import GENERATE_CONFIG_SUBCMD, MAIN_PROG_NAME, VERSION_SUBCMD @@ -19,6 +23,9 @@ from tests.conftest import RunCliFn from tests.fixtures.example_project import ExProjectDir +# Constant +NULL_BYTE = b"\x00" + @pytest.fixture def raw_config_dict() -> dict[str, Any]: @@ -157,3 +164,74 @@ def test_generate_config_pyproject_toml( # Evaluate: Check that the version command in noop mode ran successfully # which means PSR loaded the configuration successfully assert_successful_exit_code(result, cli_cmd) + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific encoding check") +@pytest.mark.parametrize( + "console_executable", + ( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + # "C:\\Windows\\System32\\cmd.exe", # CMD.exe does not support specifying encoding for output + ), +) +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_generate_config_toml_utf8_bytes_windows( + console_executable: str, + example_project_dir: ExProjectDir, + run_cli: RunCliFn, +) -> None: + """ + Given an example project directory + When generating a TOML configuration file via Powershell redirection + Then the emitted file contains only UTF-8 bytes and no NUL bytes + """ + if "powershell.exe" not in console_executable.lower(): + pytest.skip("Only PowerShell is currently supported for this test") + + output_file = example_project_dir / "releaserc.toml" + psr_cmd = [ + python_interpreter, + "-m", + semantic_release.__name__, + GENERATE_CONFIG_SUBCMD, + "-f", + "toml", + ] + + redirection_cmd = ( + f"{str.join(' ', psr_cmd)} | Out-File -Encoding utf8 {output_file}" + ) + + # Act: Generate the config file via subprocess call to PowerShell + proc = subprocess.run( # noqa: S602, not a security concern in testing & required for redirection + redirection_cmd, + executable=console_executable, + shell=True, + stdin=None, + capture_output=True, + check=True, + ) + + config_as_bytes = output_file.read_bytes() + assert config_as_bytes, "Generated config file is empty!" + assert ( + NULL_BYTE not in config_as_bytes + ), f"Generated config file '{output_file}' contains NUL bytes!" + assert not proc.stderr + assert not proc.stdout + + # Act: Validate that the generated config is a valid configuration for PSR + cli_cmd = [ + MAIN_PROG_NAME, + "--noop", + "--strict", + "-c", + str(output_file), + VERSION_SUBCMD, + "--print", + ] + result = run_cli(cli_cmd[1:]) + + # Evaluate: Check that the version command in noop mode ran successfully + # which means PSR loaded the configuration successfully + assert_successful_exit_code(result, cli_cmd) From 95ce7ecdbab0fc0986d1fcf442cd8cf99a4b6e4f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:49:01 -0800 Subject: [PATCH 6/6] feat(cmd-version): add file replacement variant for `version_variables` (#1391) Add support for entire file replacement when pattern is specified as `*`. This allows users to configure version stamping for files that contain only a version number (e.g., VERSION files). Implements: #1375 * docs(configuration): modify `version_variables` definition to include new file replacement * test(cmd-version): add version stamp test for a version file * test(version): add unit test for file declaration --- docs/configuration/configuration.rst | 55 +++- src/semantic_release/cli/config.py | 21 +- src/semantic_release/version/declaration.py | 4 +- .../version/declarations/file.py | 145 +++++++++ .../declarations/i_version_replacer.py | 6 + tests/e2e/cmd_version/test_version_stamp.py | 130 ++++++++ .../declarations/test_file_declaration.py | 279 ++++++++++++++++++ 7 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 src/semantic_release/version/declarations/file.py create mode 100644 tests/unit/semantic_release/version/declarations/test_file_declaration.py diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 690a3e4c3..74e7638ba 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1326,6 +1326,10 @@ colon-separated definition with either 2 or 3 parts. The 2-part definition inclu 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. +As of ${NEW_RELEASE_TAG}, the ``version_variables`` option also supports entire file +replacement by using an asterisk (``*``) as the pattern/variable name. This is useful +for files that contain only a version number, such as ``VERSION`` files. + **Available Format Types** - ``nf``: Number format (ex. ``1.2.3``) @@ -1348,6 +1352,9 @@ version numbers. "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 + # File replacement (entire file content is replaced with version) + "VERSION:*:nf", # Replace entire file with number format + "RELEASE:*:tf", # Replace entire file with tag format ] First, the ``__version__`` variable in ``src/semantic_release/__init__.py`` will be updated @@ -1370,7 +1377,7 @@ with the next version using the `SemVer`_ number format because of the explicit - version = "0.1.0" + version = "0.2.0" -Lastly, the ``newTag`` variable in ``kustomization.yml`` will be updated with the next version +Then, 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``. @@ -1383,10 +1390,34 @@ included ``tf``. - newTag: v0.1.0 + newTag: v0.2.0 +Next, the entire content of the ``VERSION`` file will be replaced with the next version +using the `SemVer`_ number format (because of the ``*`` pattern and ``nf`` format type). + +.. code-block:: diff + + diff a/VERSION b/VERSION + + - 0.1.0 + + 0.2.0 + +Finally, the entire content of the ``RELEASE`` file will be replaced with the next version +using the configured :ref:`config-tag_format` (because of the ``*`` pattern and ``tf`` format type). + +.. code-block:: diff + + diff a/RELEASE b/RELEASE + + - v0.1.0 + + 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 +Each version variable will be transformed into either a Regular Expression (for pattern-based +replacement) or a file replacement operation (when using the ``*`` pattern). + +**Pattern-Based Replacement** + +When a variable name is specified (not ``*``), the replacement algorithm is **ONLY** a 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). @@ -1420,6 +1451,24 @@ 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. +**File Replacement** + +When the pattern/variable name is specified as an asterisk (``*``), the entire file content +will be replaced with the version string. This is useful for files that contain only a +version number, such as ``VERSION`` files or similar single-line version storage files. + +The file replacement operation: + +1. Reads the current file content if it exists (any whitespace is stripped) +2. Sets or replaces the entire file content with the new version string +3. Writes the new version back to the file (with only a single trailing newline) + +The format type (``nf`` or ``tf``) determines whether the version is written as a +plain number (e.g., ``1.2.3``) or with the :ref:`config-tag_format` prefix/suffix +(e.g., ``v1.2.3``). + +**Examples of Pattern-Based Replacement** + This is a comprehensive list (but not all variations) of examples where the following versions will be matched and replaced by the new version: diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 514d76ef1..76ccd1e68 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -57,6 +57,7 @@ ) from semantic_release.globals import logger from semantic_release.helpers import dynamic_import +from semantic_release.version.declarations.file import FileVersionDeclaration 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 @@ -757,12 +758,22 @@ def from_raw_config( # noqa: C901 ) from err try: - version_declarations.extend( - PatternVersionDeclaration.from_string_definition( - definition, raw.tag_format + for definition in iter(raw.version_variables or ()): + # Check if this is a file replacement definition (pattern is "*") + parts = definition.split(":", maxsplit=2) + if len(parts) >= 2 and parts[1] == "*": + # Use FileVersionDeclaration for entire file replacement + version_declarations.append( + FileVersionDeclaration.from_string_definition(definition) + ) + continue + + # Use PatternVersionDeclaration for pattern-based replacement + version_declarations.append( + PatternVersionDeclaration.from_string_definition( + definition, raw.tag_format + ) ) - for definition in iter(raw.version_variables or ()) - ) except ValueError as err: raise InvalidConfiguration( str.join( diff --git a/src/semantic_release/version/declaration.py b/src/semantic_release/version/declaration.py index e0400f3df..c7124f526 100644 --- a/src/semantic_release/version/declaration.py +++ b/src/semantic_release/version/declaration.py @@ -9,6 +9,7 @@ from semantic_release.globals import logger from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.file import FileVersionDeclaration 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 @@ -19,11 +20,12 @@ # Globals __all__ = [ + "FileVersionDeclaration", "IVersionReplacer", - "VersionStampType", "PatternVersionDeclaration", "TomlVersionDeclaration", "VersionDeclarationABC", + "VersionStampType", ] diff --git a/src/semantic_release/version/declarations/file.py b/src/semantic_release/version/declarations/file.py new file mode 100644 index 000000000..7d6c9732c --- /dev/null +++ b/src/semantic_release/version/declarations/file.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from deprecated.sphinx import deprecated + +from semantic_release.globals import logger +from semantic_release.version.declarations.enum import VersionStampType +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer + +if TYPE_CHECKING: # pragma: no cover + from semantic_release.version.version import Version + + +class FileVersionDeclaration(IVersionReplacer): + """ + IVersionReplacer implementation that replaces the entire file content + with the version string. + + This is useful for files that contain only a version number, such as + VERSION files or similar single-line version storage files. + """ + + def __init__(self, path: Path | str, stamp_format: VersionStampType) -> None: + self._content: str | None = None + self._path = Path(path).resolve() + self._stamp_format = stamp_format + + @property + def content(self) -> str: + """A cached property that stores the content of the configured source file.""" + if self._content is None: + logger.debug("No content stored, reading from source file %s", self._path) + + if not self._path.exists(): + logger.debug( + f"path {self._path!r} does not exist, assuming empty content" + ) + self._content = "" + else: + self._content = self._path.read_text() + + return self._content + + @content.deleter + def content(self) -> None: + self._content = None + + @deprecated( + version="10.6.0", + reason="Function is unused and will be removed in a future release", + ) + def parse(self) -> set[Version]: + raise NotImplementedError # pragma: no cover + + def replace(self, new_version: Version) -> str: + """ + Replace the file content with the new version string. + + :param new_version: The new version number as a `Version` instance + :return: The new content (just the version string) + """ + new_content = ( + new_version.as_tag() + if self._stamp_format == VersionStampType.TAG_FORMAT + else str(new_version) + ) + + logger.debug( + "Replacing entire file content: path=%r old_content=%r new_content=%r", + self._path, + self.content.strip(), + new_content, + ) + + return new_content + + def update_file_w_version( + self, new_version: Version, noop: bool = False + ) -> Path | None: + if noop: + if not self._path.exists(): + logger.warning( + f"FILE NOT FOUND: file '{self._path}' does not exist but it will be created" + ) + + return self._path + + new_content = self.replace(new_version) + if new_content == self.content.strip(): + return None + + self._path.write_text(f"{new_content}\n") + del self.content + + return self._path + + @classmethod + def from_string_definition(cls, replacement_def: str) -> FileVersionDeclaration: + """ + Create an instance of self from a string representing one item + of the "version_variables" list in the configuration. + + This method expects a definition in the format: + "file:*:format_type" + + where: + - file is the path to the file + - * is the literal asterisk character indicating file replacement + - format_type is either "nf" (number format) or "tf" (tag format) + """ + 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, pattern, version_type = parts + + # Validate that the pattern is exactly "*" + if pattern != "*": + raise ValueError( + f"Invalid pattern {pattern!r} for FileVersionDeclaration, expected '*'" + ) + + 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, stamp_type) diff --git a/src/semantic_release/version/declarations/i_version_replacer.py b/src/semantic_release/version/declarations/i_version_replacer.py index fcee56564..6d519da7b 100644 --- a/src/semantic_release/version/declarations/i_version_replacer.py +++ b/src/semantic_release/version/declarations/i_version_replacer.py @@ -3,6 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING +from deprecated.sphinx import deprecated + if TYPE_CHECKING: # pragma: no cover from pathlib import Path @@ -32,6 +34,10 @@ def __subclasshook__(cls, subclass: type) -> bool: ) ) + @deprecated( + version="9.20.0", + reason="Function is unused and will be removed in a future release", + ) @abstractmethod def parse(self) -> set[Version]: """ diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index 2bce55901..997357ddd 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from os import linesep from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING, cast @@ -498,3 +499,132 @@ def test_stamp_version_variables_yaml_kustomization_container_spec( resulting_yaml_obj["images"][0]["newTag"] = original_yaml_obj["images"][0]["newTag"] assert original_yaml_obj == resulting_yaml_obj + + +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_variables_file_replacement_number_format( + run_cli: RunCliFn, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Given a VERSION file with a version number, + When a version is stamped and configured to replace the entire file with number format, + Then the entire file content is replaced with the new version number + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/1375 + """ + orig_version = "0.0.0" + new_version = "1.0.0" + target_file = Path("VERSION") + + # Setup: Write initial text in file + target_file.write_text(orig_version) + + # Setup: Set configuration to replace the entire file + update_pyproject_toml( + "tool.semantic_release.version_variables", + [ + f"{target_file}:*:{VersionStampType.NUMBER_FORMAT.value}", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = run_cli(cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + with target_file.open(newline="") as rfd: + resulting_content = rfd.read() + + # Check the version was updated (entire file should be just the version) + assert f"{new_version}{linesep}" == resulting_content + + +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_variables_file_replacement_tag_format( + run_cli: RunCliFn, + update_pyproject_toml: UpdatePyprojectTomlFn, + default_tag_format_str: str, +) -> None: + """ + Given a VERSION file with a version tag, + When a version is stamped and configured to replace the entire file with tag format, + Then the entire file content is replaced with the new version in tag format + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/1375 + """ + orig_version = "0.0.0" + new_version = "1.0.0" + target_file = Path("VERSION") + orig_tag = default_tag_format_str.format(version=orig_version) + expected_new_tag = default_tag_format_str.format(version=new_version) + + # Setup: Write initial text in file + target_file.write_text(orig_tag) + + # Setup: Set configuration to replace the entire file + update_pyproject_toml( + "tool.semantic_release.version_variables", + [ + f"{target_file}:*:{VersionStampType.TAG_FORMAT.value}", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = run_cli(cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + with target_file.open(newline="") as rfd: + resulting_content = rfd.read() + + # Check the version was updated (entire file should be just the tag) + assert f"{expected_new_tag}{linesep}" == resulting_content + + +@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) +def test_stamp_version_variables_file_replacement_with_whitespace( + run_cli: RunCliFn, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Given a VERSION file with a version number and trailing whitespace, + When a version is stamped and configured to replace the entire file, + Then the entire file content is replaced with just the new version (no whitespace) + + Based on https://github.com/python-semantic-release/python-semantic-release/issues/1375 + """ + orig_version = "0.0.0" + new_version = "1.0.0" + target_file = Path("VERSION") + + # Setup: Write initial text in file with trailing whitespace + target_file.write_text(f" {orig_version} \n") + + # Setup: Set configuration to replace the entire file + update_pyproject_toml( + "tool.semantic_release.version_variables", + [ + f"{target_file}:*", + ], + ) + + # Act + cli_cmd = VERSION_STAMP_CMD + result = run_cli(cli_cmd[1:]) + + # Check the result + assert_successful_exit_code(result, cli_cmd) + + # Read content + with target_file.open(newline="") as rfd: + resulting_content = rfd.read() + + # Check the version was updated (entire file should be just the version, only trailing newline) + assert f"{new_version}{linesep}" == resulting_content diff --git a/tests/unit/semantic_release/version/declarations/test_file_declaration.py b/tests/unit/semantic_release/version/declarations/test_file_declaration.py new file mode 100644 index 000000000..38ae84237 --- /dev/null +++ b/tests/unit/semantic_release/version/declarations/test_file_declaration.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from os import linesep +from pathlib import Path + +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.file import FileVersionDeclaration +from semantic_release.version.declarations.i_version_replacer import IVersionReplacer +from semantic_release.version.version import Version + +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.git_repo import default_tag_format_str + + +def test_file_declaration_is_version_replacer(): + """ + Given the class FileVersionDeclaration or an instance of it, + When the class is evaluated as a subclass or an instance of, + Then the evaluation is true + """ + assert issubclass(FileVersionDeclaration, IVersionReplacer) + + file_instance = FileVersionDeclaration("file", VersionStampType.NUMBER_FORMAT) + assert isinstance(file_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 ["VERSION"] + for next_version in ["1.2.3"] + for test_id, replacement_def, tag_format, starting_contents, resulting_contents in [ + ( + "Default number format for file replacement", + f"{test_file}:*", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # File contains only version + "1.0.0", + f"{next_version}{linesep}", + ), + ( + "Explicit number format for file replacement", + f"{test_file}:*:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # File contains only version + "1.0.0", + f"{next_version}{linesep}", + ), + ( + "Using default tag format for file replacement", + f"{test_file}:*:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # File contains version with v-prefix + "v1.0.0", + f"v{next_version}{linesep}", + ), + ( + "Using custom tag format for file replacement", + f"{test_file}:*:{VersionStampType.TAG_FORMAT.value}", + "module-v{version}", + # File contains version with custom prefix + "module-v1.0.0", + f"module-v{next_version}{linesep}", + ), + ( + "File with trailing newline", + f"{test_file}:*", + lazy_fixture(default_tag_format_str.__name__), + # File contains version with newline + "1.0.0\n", + f"{next_version}{linesep}", + ), + ( + "File with whitespace", + f"{test_file}:*", + lazy_fixture(default_tag_format_str.__name__), + # File contains version with whitespace + " 1.0.0 \n", + f"{next_version}{linesep}", + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_file_declaration_from_definition( + replacement_def: str, + tag_format: str, + starting_contents: str, + resulting_contents: str, + next_version: str, + test_file: str, +): + """ + Given a file with a version string as its content, + When update_file_w_version() is called with a new version, + Then the entire file is replaced with the new version string in the specified tag or number format + """ + # Setup: create file with initial contents + expected_filepath = Path(test_file).resolve() + expected_filepath.write_text(starting_contents) + + # Create File Replacer + version_replacer = FileVersionDeclaration.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 + + +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_file_declaration_no_file_change(): + """ + 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 = "VERSION" + expected_filepath = Path(test_file).resolve() + next_version = Version.parse("1.2.3") + starting_contents = f"{next_version}{linesep}" + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create File Replacer + version_replacer = FileVersionDeclaration.from_string_definition( + f"{test_file}:*:{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 = expected_filepath.read_text() + assert starting_contents == actual_contents + assert file_modified is None + + +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_file_declaration_creates_when_missing_file(): + new_version = Version.parse("1.2.3") + expected_contents = f"{new_version}{linesep}" + missing_file_path = Path("nonexistent_file") + + # Ensure missing file does not exist before test + if missing_file_path.exists(): + missing_file_path.unlink() + + # Create File Replacer + version_replacer = FileVersionDeclaration.from_string_definition( + f"{missing_file_path}:*", + ) + + # Act: apply version change + version_replacer.update_file_w_version( + new_version=new_version, + noop=False, + ) + + # Evaluate + assert missing_file_path.exists() + actual_contents = missing_file_path.read_text() + assert expected_contents == actual_contents + + +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_file_declaration_noop_is_noop(): + test_file = "VERSION" + expected_filepath = Path(test_file).resolve() + starting_contents = "1.0.0" + + # Setup: create file with initial contents + expected_filepath.write_text(starting_contents) + + # Create File Replacer + version_replacer = FileVersionDeclaration.from_string_definition( + f"{test_file}:*:{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 + + +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_file_declaration_noop_warning_on_missing_file( + caplog: pytest.LogCaptureFixture, +): + missing_file_name = Path("nonexistent_file") + expected_warning = f"FILE NOT FOUND: file '{missing_file_name.resolve()}' does not exist but it will be created" + version_replacer = FileVersionDeclaration.from_string_definition( + f"{missing_file_name}:*", + ) + + file_to_modify = version_replacer.update_file_w_version( + new_version=Version.parse("1.2.3"), + noop=True, + ) + + # Evaluate + assert missing_file_name.resolve() == file_to_modify + assert expected_warning in caplog.text + + +@pytest.mark.parametrize( + "replacement_def, error_msg", + [ + pytest.param( + replacement_def, + error_msg, + id=str(error_msg), + ) + for replacement_def, error_msg in [ + ( + "test_file", + "Invalid replacement definition", + ), + ( + "test_file:*:not_a_valid_version_type", + "Invalid stamp type, must be one of:", + ), + ( + "test_file:not_asterisk:nf", + "Invalid pattern 'not_asterisk' for FileVersionDeclaration, expected '*'", + ), + ] + ], +) +def test_file_declaration_w_invalid_definition( + replacement_def: str, + error_msg: str, +): + """ + Check if FileVersionDeclaration raises ValueError when loaded + from invalid strings given in the config file + """ + with pytest.raises(ValueError, match=error_msg): + FileVersionDeclaration.from_string_definition(replacement_def)