Source code for repo_helper.release

#!/usr/bin/env python
#
#  release.py
"""
Functions for making tagged releases.

.. versionadded:: 2020.12.29
"""
#
#  Copyright © 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#  GNU Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#

# stdlib
import os
from datetime import date
from typing import Any, Dict, Iterable, Optional

# 3rd party
import click
from consolekit.terminal_colours import Fore
from consolekit.utils import abort
from domdf_python_tools.paths import PathPlus, traverse_to_file
from domdf_python_tools.typing import PathLike
from dulwich.porcelain import tag_create
from packaging.version import Version
from southwark import assert_clean
from southwark.repo import Repo
from typing_extensions import TypedDict

# this package
from repo_helper.cli.utils import commit_changed_files
from repo_helper.configupdater2 import ConfigUpdater
from repo_helper.core import RepoHelper
from repo_helper.files.ci_cd import get_bumpversion_filenames

__all__ = ["Bumper", "BumpversionFileConfig"]


class _Version(Version):

	@classmethod
	def from_parts(
			cls,
			release: Iterable,
			pre: Optional[Iterable] = None,
			post: Optional[Any] = None,
			dev: Optional[Any] = None,
			local: Optional[Any] = None
			) -> Version:

		# Release segment
		parts = ['.'.join(str(x) for x in release)]

		# Pre-release
		if pre is not None:
			parts.append(''.join(str(x) for x in pre))

		# Post-release
		if post is not None:
			parts.append(f".post{post}")

		# Development release
		if dev is not None:
			parts.append(f".dev{dev}")

		# Local version segment
		if local is not None:
			parts.append(f"+{local}")

		return cls(''.join(parts))


[docs]class BumpversionFileConfig(TypedDict): """ Represents the subset of ``bumpversion`` per-file configuration values used by ``repo-helper``. """ search: str replace: str
[docs]class Bumper: """ Class to bump the repository version. :param repo_path: :param force: Whether to force bumping the version when the repository is unclean. """ def __init__(self, repo_path: PathPlus, force: bool = False): #: self.repo = RepoHelper(traverse_to_file(PathPlus(repo_path), "repo_helper.yml")) self.repo.load_settings() if not assert_clean(self.repo.target_repo): if force: click.echo(Fore.RED("Proceeding anyway"), err=True) else: raise click.Abort # pypi_secure_key = "travis_pypi_secure" # if self.repo.templates.globals["on_pypi"] and not self.repo.templates.globals[pypi_secure_key]: # raise abort(f"Cowardly refusing to bump the version when {pypi_secure_key!r} is unset.") # TODO: Handle this wrt github actions #: self.current_version = self.get_current_version() #: The path to the bumpversion configuration file. self.bumpversion_file = self.repo.target_repo / ".bumpversion.cfg"
[docs] def major(self, commit: Optional[bool], message: str): """ Bump to the next major version. :param commit: Whether to commit automatically (:py:obj:`True`) or ask first (:py:obj:`None`). :param message: The commit message. """ new_version = _Version.from_parts((self.current_version.major + 1, 0, 0)) self.bump(new_version, commit, message)
[docs] def minor(self, commit: Optional[bool], message: str): """ Bump to the next minor version. :param commit: Whether to commit automatically (:py:obj:`True`) or ask first (:py:obj:`None`). :param message: The commit message. """ new_version = _Version.from_parts((self.current_version.major, self.current_version.minor + 1, 0)) self.bump(new_version, commit, message)
[docs] def patch(self, commit: Optional[bool], message: str): """ Bump to the next patch version. :param commit: Whether to commit automatically (:py:obj:`True`) or ask first (:py:obj:`None`). :param message: The commit message. """ new_version = _Version.from_parts(( self.current_version.major, self.current_version.minor, self.current_version.micro + 1, )) self.bump(new_version, commit, message)
[docs] def today(self, commit: Optional[bool], message: str): """ Bump to the calver version for today's date. E.g. 2020.12.25 for 25th December 2020 :param commit: Whether to commit automatically (:py:obj:`True`) or ask first (:py:obj:`None`). :param message: The commit message. """ today = date.today() new_version = _Version.from_parts((today.year, today.month, today.day)) self.bump(new_version, commit, message)
[docs] def bump(self, new_version: Version, commit: Optional[bool], message: str): """ Bump to the given version. :param new_version: :param commit: Whether to commit automatically (:py:obj:`True`) or ask first (:py:obj:`None`). :param message: The commit message. .. versionchanged:: 2021.8.11 Now takes a :class:`packaging.version.Version` rather than a :class:`domdf_python_tools.versions.Version`. """ new_version_str = str(new_version) dulwich_repo = Repo(self.repo.target_repo) if f"v{new_version_str}".encode("UTF-8") in dulwich_repo.refs.as_dict(b"refs/tags"): raise abort(f"The tag 'v{new_version_str}' already exists!") bumpversion_config = self.get_bumpversion_config(str(self.current_version), new_version_str) changed_files = [self.bumpversion_file.relative_to(self.repo.target_repo).as_posix()] for filename in bumpversion_config.keys(): if not os.path.isfile(filename): raise FileNotFoundError(filename) for filename, config in bumpversion_config.items(): self.bump_version_for_file(filename, config) changed_files.append(filename) # Update number in .bumpversion.cfg bv = ConfigUpdater() bv.read(self.bumpversion_file) bv["bumpversion"]["current_version"] = new_version_str self.bumpversion_file.write_clean(str(bv)) commit_message = message.format(current_version=self.current_version, new_version=new_version) click.echo(commit_message) if commit_changed_files( self.repo.target_repo, managed_files=changed_files, commit=commit, message=commit_message.encode("UTF-8"), enable_pre_commit=False, ): tag_create(dulwich_repo, f"v{new_version_str}")
[docs] def get_current_version(self) -> Version: """ Returns the current version from the ``repo_helper.yml`` configuration file. """ return Version(self.repo.templates.globals["version"])
[docs] def get_bumpversion_config( self, current_version: str, new_version: str, ) -> Dict[str, BumpversionFileConfig]: """ Returns the bumpversion config. :param current_version: :param new_version: """ bv = ConfigUpdater() bv.read(self.bumpversion_file) def default(): return {"search": current_version, "replace": new_version} # populate with the sections which are managed by repo_helper config: Dict[str, BumpversionFileConfig] = { filename: default() for filename in get_bumpversion_filenames(self.repo.templates) } if self.repo.templates.globals["enable_docs"]: config[f"{self.repo.templates.globals['docs_dir']}/index.rst"] = default() for section in bv.sections(): if not section.startswith("bumpversion:file:"): continue section_dict: Dict[str, str] = bv[section].to_dict() config[section[17:]] = dict( search=section_dict.get("search", "{current_version}").format(current_version=current_version), replace=section_dict.get("replace", "{new_version}").format(new_version=new_version), ) return config
[docs] def bump_version_for_file(self, filename: PathLike, config: BumpversionFileConfig): """ Bumps the version for the given file. :param filename: :param config: """ filename = self.repo.target_repo / filename filename.write_text(filename.read_text().replace(config["search"], config["replace"]))