| """Utility functions for handling news entries and merging them into CHANGELOG.md.""" | |
| import pathlib | |
| import re | |
| def _get_sub_category(content): | |
| """Extracts the sub-category in parentheses from the entry content.""" | |
| match = re.match(r"^(?:\*|-)\s*\(([^)]+)\)", content) | |
| if match: | |
| return match.group(1).lower() | |
| return "" | |
| def is_news_file(path): | |
| """Checks if a file path is a valid news file.""" | |
| path = pathlib.Path(path) | |
| if not path.is_file(): | |
| return False | |
| if path.suffix != ".md": | |
| return False | |
| parts = path.name.split(".") | |
| if len(parts) < 3: | |
| return False | |
| return True | |
| def _get_news_files(news_dir): | |
| """Returns a list of news files matching the <id>.<category>.md pattern.""" | |
| news_path = pathlib.Path(news_dir) | |
| if not news_path.exists(): | |
| return [] | |
| return [p for p in news_path.iterdir() if is_news_file(p)] | |
| def _format_entry(content): | |
| """Formats news entry content as a markdown bullet list item. | |
| Continuation lines are indented with two spaces. | |
| """ | |
| lines = content.strip().splitlines() | |
| if not lines: | |
| return "" | |
| formatted_lines = [] | |
| first_line = lines[0] | |
| if not (first_line.startswith("* ") or first_line.startswith("- ")): | |
| formatted_lines.append(f"* {first_line}") | |
| else: | |
| formatted_lines.append(first_line) | |
| for line in lines[1:]: | |
| if not line: | |
| formatted_lines.append("") | |
| elif line.startswith("* ") or line.startswith("- "): | |
| formatted_lines.append(line) | |
| elif line.startswith(" "): | |
| formatted_lines.append(line) | |
| else: | |
| formatted_lines.append(f" {line}") | |
| return "\n".join(formatted_lines) | |
| def _parse_new_files(news_files): | |
| """Parses news files and groups them by category.""" | |
| entries = {} | |
| for p in news_files: | |
| if not is_news_file(p): | |
| continue | |
| parts = p.name.split(".") | |
| category = parts[1].lower() | |
| content = p.read_text(encoding="utf-8").strip() | |
| if not content: | |
| continue | |
| formatted_content = _format_entry(content) | |
| if not formatted_content: | |
| continue | |
| if category not in entries: | |
| entries[category] = [] | |
| entries[category].append(formatted_content) | |
| return entries | |
| def generate_release_block(version, release_date, news_entries): | |
| """Generates the markdown block for the release.""" | |
| header_version = version.replace(".", "-") | |
| lines = [ | |
| f"{{#v{header_version}}}", | |
| f"## [{version}] - {release_date}", | |
| "", | |
| f"[{version}]: https://github.com/bazel-contrib/rules_python/releases/tag/{version}", | |
| "", | |
| ] | |
| # Standard categories in preferred order | |
| category_order = ["removed", "changed", "fixed", "added"] | |
| # Add any other categories found | |
| for cat in news_entries: | |
| if cat not in category_order: | |
| category_order.append(cat) | |
| has_entries = False | |
| for cat in category_order: | |
| if cat in news_entries and news_entries[cat]: | |
| has_entries = True | |
| lines.append(f"{{#v{header_version}-{cat}}}") | |
| lines.append(f"### {cat.capitalize()}") | |
| # Sort entries by sub-category, then by content | |
| sorted_entries = sorted( | |
| news_entries[cat], key=lambda e: (_get_sub_category(e), e) | |
| ) | |
| for entry in sorted_entries: | |
| lines.append(entry) | |
| lines.append("") | |
| if not has_entries: | |
| lines.append("No notable changes.") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _parse_simple_version(ver_str): | |
| """Parses a version string (X-Y-Z or X.Y.Z) into a tuple of ints.""" | |
| normalized = ver_str.replace("-", ".") | |
| return tuple(int(x) for x in normalized.split(".")) | |
| def _find_insertion_point(changelog_content, new_version): | |
| """Finds the character index to insert the new version block. | |
| It should be inserted before the first version in the changelog that is | |
| smaller than new_version. | |
| """ | |
| # Find all version anchors: {#vX-Y-Z} | |
| matches = list(re.finditer(r"\{#v(?P<ver>\d+-\d+-\d+)\}", changelog_content)) | |
| parsed_new_ver = _parse_simple_version(new_version) | |
| for m in matches: | |
| ver_str = m.group("ver") | |
| parsed_ver = _parse_simple_version(ver_str) | |
| if parsed_ver < parsed_new_ver: | |
| return m.start() | |
| raise ValueError( | |
| f"Could not find a version in CHANGELOG.md smaller than {new_version} to insert before." | |
| ) | |
| def _add_news_to_changelog(input_path, output_path, version, entries, release_date): | |
| """Adds or merges news entries into CHANGELOG.md.""" | |
| input_path = pathlib.Path(input_path) | |
| output_path = pathlib.Path(output_path) | |
| changelog_content = input_path.read_text(encoding="utf-8") | |
| if version == "unreleased": | |
| header_version = "unreleased" | |
| version_anchor = "{#unreleased}" | |
| category_anchor_fmt = "{{#unreleased-{cat}}}" | |
| category_anchor_pattern = r"\{#unreleased-(?P<cat>[a-z]+)\}" | |
| else: | |
| header_version = version.replace(".", "-") | |
| version_anchor = f"{{#v{header_version}}}" | |
| category_anchor_fmt = "{{#v" + header_version + "-{cat}}}" | |
| category_anchor_pattern = ( | |
| r"\{#v" + re.escape(header_version) + r"-(?P<cat>[a-z]+)\}" | |
| ) | |
| version_exists = version_anchor in changelog_content | |
| if version_exists: | |
| if not entries and version != "unreleased": | |
| print( | |
| f"Version {version} already exists and no news entries found" | |
| " to merge. Doing nothing." | |
| ) | |
| output_path.write_text(changelog_content, encoding="utf-8") | |
| return | |
| print(f"Version {version} already exists in changelog. Merging news entries...") | |
| # Extract the existing version block | |
| pattern = ( | |
| r"(?P<anchor>" | |
| + re.escape(version_anchor) | |
| + r")(?P<content>.*?)(?=\n\s*\{#v\d+-\d+-\d+\}|\Z)" | |
| ) | |
| match = re.search(pattern, changelog_content, re.DOTALL) | |
| if not match: | |
| raise RuntimeError( | |
| f"Could not find content for existing version {version} in CHANGELOG.md" | |
| ) | |
| content_block = match.group("content") | |
| # Strip the "Unreleased changes..." sentence for Unreleased preview | |
| if version == "unreleased": | |
| content_block = re.sub( | |
| r"Unreleased\s+changes\s+are\s+tracked\s+as\s+individual\s+files\s+in\s+the\s+\[news/\]\(\./news\)\s+directory,\s+or\s+view\s+the\s+\[latest\s+generated\s+changelog\]\(https://rules-python\.readthedocs\.io/en/latest/changelog\.html\)\.\s*\n*", | |
| "", | |
| content_block, | |
| ) | |
| # Parse existing categories | |
| match_cat = re.search(category_anchor_pattern, content_block) | |
| if match_cat: | |
| header_end_idx = match_cat.start() | |
| header_str = content_block[:header_end_idx] | |
| categories_str = content_block[header_end_idx:] | |
| else: | |
| header_str = content_block | |
| categories_str = "" | |
| existing_entries = {} | |
| if categories_str: | |
| cat_matches = list(re.finditer(category_anchor_pattern, categories_str)) | |
| for i, m in enumerate(cat_matches): | |
| cat = m.group("cat") | |
| start_idx = m.end() | |
| end_idx = ( | |
| cat_matches[i + 1].start() | |
| if i + 1 < len(cat_matches) | |
| else len(categories_str) | |
| ) | |
| cat_content = categories_str[start_idx:end_idx].strip() | |
| lines = cat_content.splitlines() | |
| cat_entries = [] | |
| current_entry = [] | |
| for line in lines: | |
| if not line.strip() or line.strip().startswith("### "): | |
| continue | |
| if line.startswith("* ") or line.startswith("- "): | |
| if current_entry: | |
| cat_entries.append("\n".join(current_entry)) | |
| current_entry = [line] | |
| else: | |
| if current_entry: | |
| current_entry.append(line) | |
| if current_entry: | |
| cat_entries.append("\n".join(current_entry)) | |
| existing_entries[cat] = cat_entries | |
| # Merge news entries | |
| merged_entries = dict(existing_entries) | |
| for cat, cat_entries in entries.items(): | |
| if cat not in merged_entries: | |
| merged_entries[cat] = [] | |
| merged_entries[cat].extend(cat_entries) | |
| # Reconstruct categories | |
| reconstructed_lines = [] | |
| category_order = ["removed", "changed", "fixed", "added"] | |
| for cat in merged_entries: | |
| if cat not in category_order: | |
| category_order.append(cat) | |
| for cat in category_order: | |
| if cat in merged_entries and merged_entries[cat]: | |
| reconstructed_lines.append(category_anchor_fmt.format(cat=cat)) | |
| reconstructed_lines.append(f"### {cat.capitalize()}") | |
| sorted_entries = sorted( | |
| merged_entries[cat], key=lambda e: (_get_sub_category(e), e) | |
| ) | |
| for entry in sorted_entries: | |
| reconstructed_lines.append(entry) | |
| reconstructed_lines.append("") | |
| new_categories_str = "\n".join(reconstructed_lines) | |
| new_release_block = ( | |
| header_str.rstrip() + "\n\n" + new_categories_str.strip() + "\n" | |
| ) | |
| if version == "unreleased" and not new_categories_str.strip(): | |
| new_release_block = ( | |
| header_str.rstrip() + "\n\nNo notable unreleased changes.\n" | |
| ) | |
| # Replace in changelog_content | |
| new_content = re.sub( | |
| pattern, | |
| r"\g<anchor>\n" + new_release_block.strip() + "\n", | |
| changelog_content, | |
| count=1, | |
| flags=re.DOTALL, | |
| ) | |
| output_path.write_text(new_content, encoding="utf-8") | |
| else: | |
| print( | |
| f"Version {version} does not exist in changelog. Creating new" | |
| " release section from news entries..." | |
| ) | |
| new_release_block = generate_release_block(version, release_date, entries) | |
| # Find insertion point | |
| insertion_point = _find_insertion_point(changelog_content, version) | |
| new_content = ( | |
| changelog_content[:insertion_point] | |
| + new_release_block | |
| + "\n\n" | |
| + changelog_content[insertion_point:] | |
| ) | |
| output_path.write_text(new_content, encoding="utf-8") | |
| def merge_new_into_changelog( | |
| changelog_path, | |
| output_path, | |
| news_dir, | |
| version, | |
| release_date, | |
| delete_news=False, | |
| news_files=None, | |
| ): | |
| """Merges news entries from news_dir into changelog_path and writes to output_path.""" | |
| if news_files is None: | |
| news_files = _get_news_files(news_dir) | |
| else: | |
| news_files = [pathlib.Path(f) for f in news_files] | |
| entries = _parse_new_files(news_files) | |
| _add_news_to_changelog( | |
| input_path=changelog_path, | |
| output_path=output_path, | |
| version=version, | |
| entries=entries, | |
| release_date=release_date, | |
| ) | |
| if delete_news: | |
| for p in news_files: | |
| if p.exists(): | |
| p.unlink() | |
| if news_files: | |
| print(f"Removed {len(news_files)} processed news files.") | |
| def update_changelog( | |
| version, | |
| release_date, | |
| changelog_path="CHANGELOG.md", | |
| output_path=None, | |
| news_dir="news", | |
| delete_news=True, | |
| news_files=None, | |
| ): | |
| """Performs the version replacements in CHANGELOG.md.""" | |
| if output_path is None: | |
| output_path = changelog_path | |
| merge_new_into_changelog( | |
| changelog_path=changelog_path, | |
| output_path=output_path, | |
| news_dir=news_dir, | |
| version=version, | |
| release_date=release_date, | |
| delete_news=delete_news, | |
| news_files=news_files, | |
| ) |