From 345368ae9c747965d9415e2406f0d826a058cbff Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 17:13:23 -0700 Subject: [PATCH 1/7] build(deps): add `deprecated~=1.2` for deprecation notices & sphinx documentation --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f0d1eb911..8b6c0eccd 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", ] From 5f56261348f513b302707ef85197f1bc75fc868f Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 18:15:22 -0700 Subject: [PATCH 2/7] refactor(noop): simplify text output during `--noop` execution --- src/semantic_release/cli/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 7bf5f4c55a0b53c35e0291da29e6e2fdec989e19 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 23:15:13 -0700 Subject: [PATCH 3/7] chore(mypy): set mypy configuration to ignore `dotty_dict` missing types --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8b6c0eccd..b11f2d7f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,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" From 0d74d2bf92cc7185507410c9e1e51c959a06fce3 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 18:22:54 -0700 Subject: [PATCH 4/7] feat(cmd-version): expand version stamp functionality to stamp tag formatted versions into files Resolves: #846 --- src/semantic_release/cli/commands/version.py | 45 ++-- src/semantic_release/cli/config.py | 79 +++--- 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 +++++++++++ 8 files changed, 573 insertions(+), 182 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 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/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) From dc4d81cf1d0c4f3855af076dfb787dae37cdd4b1 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 18:25:24 -0700 Subject: [PATCH 5/7] test(declarations): update unit tests for full file modification w/ tag formatted versions --- .../version/declarations/__init__.py | 0 .../declarations/test_pattern_declaration.py | 454 ++++++++++++++++++ .../declarations/test_toml_declaration.py | 350 ++++++++++++++ .../version/test_declaration.py | 138 ------ 4 files changed, 804 insertions(+), 138 deletions(-) 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/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 445dad3dad77ed6fb98fc6923fb0d879423f0c67 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 18:26:17 -0700 Subject: [PATCH 6/7] test(cmd-version): add version stamp test to validate tag formatted version stamping --- tests/e2e/cmd_version/test_version_stamp.py | 130 +++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) 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 From 49a645e9021f9d5ecf59aa70071e22ba8d6eb28b Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 16 Feb 2025 23:14:03 -0700 Subject: [PATCH 7/7] docs(configuration): add usage information for tag format version stamping --- docs/configuration.rst | 132 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 14 deletions(-) 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/