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)