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__)
[docs]
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]}"
[docs]
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
[docs]
@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
[docs]
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
[docs]
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
[docs]
@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<quote1>['"])?(?<![\\w.-]){regex_escape(variable)}(?P=quote1)?""",
# Supports walrus, equals sign, colon, or @ as assignment operator
# ignoring whitespace separation
r"\s*(:=|[:=@])\s*",
# Supports optional matching quotations around a version pattern (tag or raw format)
f"""(?P<quote2>['"])?{value_replace_pattern_str}(?P=quote2)?""",
],
)
return cls(path, search_text, stamp_type)