blob: 6381aacb342b6e32a81263f88b5a0c5fcb2d4ff6 [file]
"""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,
)