#!/usr/bin/env python3 """ Tool to help automate changes needed in commits during and after releases """ from __future__ import annotations import argparse import logging import re import sys from datetime import datetime from pathlib import Path from subprocess import run LOG = logging.getLogger(__name__) NEW_VERSION_CHANGELOG_TEMPLATE = """\ ## Unreleased ### Highlights ### Stable style ### Preview style ### Configuration ### Packaging ### Parser ### Performance ### Output ### _Blackd_ ### Integrations ### Documentation """ class NoGitTagsError(Exception): ... # TODO: Do better with alpha + beta releases # Maybe we vendor packaging library def get_git_tags(versions_only: bool = True) -> list[str]: """Pull out all tags or calvers only""" cp = run(["git", "tag"], capture_output=True, check=True, encoding="utf8") if not cp.stdout: LOG.error(f"Returned no git tags stdout: {cp.stderr}") raise NoGitTagsError git_tags = cp.stdout.splitlines() if versions_only: return [t for t in git_tags if t[0].isdigit()] return git_tags # TODO: Support sorting alpha/beta releases correctly def tuple_calver(calver: str) -> tuple[int, ...]: # mypy can't notice maxsplit below """Convert a calver string into a tuple of ints for sorting""" try: return tuple(map(int, calver.split(".", maxsplit=2))) except ValueError: return (0, 0, 0) class SourceFiles: def __init__(self, black_repo_dir: Path): # File path fun all pathlib to be platform agnostic self.black_repo_path = black_repo_dir self.changes_path = self.black_repo_path / "CHANGES.md" self.docs_path = self.black_repo_path / "docs" self.version_doc_paths = ( self.docs_path / "integrations" / "source_version_control.md", self.docs_path / "usage_and_configuration" / "the_basics.md", self.docs_path / "guides" / "using_black_with_jupyter_notebooks.md", ) self.current_version = self.get_current_version() self.next_version = self.get_next_version() def __str__(self) -> str: return f"""\ > SourceFiles ENV: Repo path: {self.black_repo_path} CHANGES.md path: {self.changes_path} docs path: {self.docs_path} Current version: {self.current_version} Next version: {self.next_version} """ def add_template_to_changes(self) -> int: """Add the template to CHANGES.md if it does not exist""" LOG.info(f"Adding template to {self.changes_path}") with self.changes_path.open("r", encoding="utf-8") as cfp: changes_string = cfp.read() if "## Unreleased" in changes_string: LOG.error(f"{self.changes_path} already has unreleased template") return 1 templated_changes_string = changes_string.replace( "# Change Log\n", f"# Change Log\n\n{NEW_VERSION_CHANGELOG_TEMPLATE}", ) with self.changes_path.open("w", encoding="utf-8") as cfp: cfp.write(templated_changes_string) LOG.info(f"Added template to {self.changes_path}") return 0 def cleanup_changes_template_for_release(self) -> None: LOG.info(f"Cleaning up {self.changes_path}") with self.changes_path.open("r", encoding="utf-8") as cfp: changes_string = cfp.read() # Change Unreleased to next version changes_string = changes_string.replace( "## Unreleased", f"## Version {self.next_version}" ) # Remove all comments changes_string = re.sub(r"(?m)^)\n+", "", changes_string) # Remove empty subheadings changes_string = re.sub(r"(?m)^###.+\n+(?=#)", "", changes_string) with self.changes_path.open("w", encoding="utf-8") as cfp: cfp.write(changes_string) LOG.debug(f"Finished Cleaning up {self.changes_path}") def get_current_version(self) -> str: """Get the latest git (version) tag as latest version""" return sorted(get_git_tags(), key=lambda k: tuple_calver(k))[-1] def get_next_version(self) -> str: """Workout the year and month + version number we need to move to""" base_calver = datetime.today().strftime("%y.%m") calver_parts = base_calver.split(".") base_calver = f"{calver_parts[0]}.{int(calver_parts[1])}" # Remove leading 0 git_tags = get_git_tags() same_month_releases = [ t for t in git_tags if t.startswith(base_calver) and "a" not in t ] if len(same_month_releases) < 1: return f"{base_calver}.0" same_month_version = same_month_releases[-1].split(".", 2)[-1] return f"{base_calver}.{int(same_month_version) + 1}" def update_repo_for_release(self) -> int: """Update CHANGES.md + doc files ready for release""" self.cleanup_changes_template_for_release() self.update_version_in_docs() return 0 # return 0 if no exceptions hit def update_version_in_docs(self) -> None: for doc_path in self.version_doc_paths: LOG.info(f"Updating black version to {self.next_version} in {doc_path}") with doc_path.open("r", encoding="utf-8") as dfp: doc_string = dfp.read() next_version_doc = doc_string.replace( self.current_version, self.next_version ) with doc_path.open("w", encoding="utf-8") as dfp: dfp.write(next_version_doc) LOG.debug( f"Finished updating black version to {self.next_version} in {doc_path}" ) def _handle_debug(debug: bool) -> None: """Turn on debugging if asked otherwise INFO default""" log_level = logging.DEBUG if debug else logging.INFO logging.basicConfig( format="[%(asctime)s] %(levelname)s: %(message)s (%(filename)s:%(lineno)d)", level=log_level, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( "-a", "--add-changes-template", action="store_true", help="Add the Unreleased template to CHANGES.md", ) parser.add_argument( "-d", "--debug", action="store_true", help="Verbose debug output" ) args = parser.parse_args() _handle_debug(args.debug) return args def main() -> int: args = parse_args() # Need parent.parent cause script is in scripts/ directory sf = SourceFiles(Path(__file__).parent.parent) if args.add_changes_template: return sf.add_template_to_changes() LOG.info(f"Current version detected to be {sf.current_version}") LOG.info(f"Next version will be {sf.next_version}") return sf.update_repo_for_release() if __name__ == "__main__": # pragma: no cover sys.exit(main())