1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
|
#!/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())
|