Source code for semantic_release.version.declaration

from __future__ import annotations

import logging
import re
from abc import ABC, abstractmethod
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__)


[docs]class VersionDeclarationABC(ABC): """ ABC for classes representing a location in which a version is declared somewhere within the source tree of the repository """ def __init__(self, path: Path | str, search_text: str) -> None: self.path = Path(path) if not self.path.exists(): raise FileNotFoundError(f"path {self.path.resolve()!r} does not exist") self.search_text = search_text self._content: str | None = None @property def content(self) -> str: """ The content of the source file in which the version is stored. This property is cached in the instance variable _content """ if self._content is None: log.debug( "No content stored, reading from source file %s", self.path.resolve() ) self._content = self.path.read_text() return self._content # mypy doesn't like properties? @content.setter # type: ignore[attr-defined] def _(self, _: Any) -> None: raise AttributeError("'content' cannot be set directly") @content.deleter # type: ignore[attr-defined] def _(self) -> None: log.debug("resetting instance-stored source file contents") self._content = None
[docs] @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. """
[docs] @abstractmethod 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 """
[docs] def write(self, content: str) -> None: r""" Write new content back to the source path. Use alongside .replace(): >>> class MyVD(VersionDeclarationABC): ... def parse(self): ... ... def replace(self, new_version: Version): ... ... def write(self, content: str): ... >>> new_version = Version.parse("1.2.3") >>> vd = MyVD("path", r"__version__ = (?P<version>\d+\d+\d+)") >>> vd.write(vd.replace(new_version)) """ log.debug("writing content to %r", self.path.resolve()) self.path.write_text(content) self._content = None
[docs]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)
[docs] 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()
[docs] 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))
[docs]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<version>...) . 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.
[docs] 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
[docs] 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