| #!/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 |
| |
| <!-- PR authors: |
| Please include the PR number in the changelog entry, not the issue number --> |
| |
| ### Highlights |
| |
| <!-- Include any especially major or disruptive changes here --> |
| |
| ### Stable style |
| |
| <!-- Changes that affect Black's stable style --> |
| |
| ### Preview style |
| |
| <!-- Changes that affect Black's preview style --> |
| |
| ### Configuration |
| |
| <!-- Changes to how Black can be configured --> |
| |
| ### Packaging |
| |
| <!-- Changes to how Black is packaged, such as dependency requirements --> |
| |
| ### Parser |
| |
| <!-- Changes to the parser or to version autodetection --> |
| |
| ### Performance |
| |
| <!-- Changes that improve Black's performance. --> |
| |
| ### Output |
| |
| <!-- Changes to Black's terminal output and error messages --> |
| |
| ### _Blackd_ |
| |
| <!-- Changes to blackd --> |
| |
| ### Integrations |
| |
| <!-- For example, Docker, GitHub Actions, pre-commit, editors --> |
| |
| ### Documentation |
| |
| <!-- Major changes to documentation and policies. Small docs changes |
| don't need a changelog entry. --> |
| """ |
| |
| |
| 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 alhpa/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.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"## {self.next_version}" |
| ) |
| |
| # Remove all comments |
| changes_string = re.sub(r"(?m)^<!--(?>(?:.|\n)*?-->)\n\n", "", changes_string) |
| |
| # Remove empty subheadings |
| changes_string = re.sub(r"(?m)^###.+\n\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()) |