Download src/kscrape_dev/packaging/release_notes.py from kneiff/kscrape: direct link, hf CLI and curl.
- Browser
- Download file 10 kB
-
https://huggingface.co/spaces/kneiff/kscrape/resolve/main/src/kscrape_dev/packaging/release_notes.py
- Command line
-
hf download hf://spaces/kneiff/kscrape/src/kscrape_dev/packaging/release_notes.py
-
curl -L -o release_notes.py https://huggingface.co/spaces/kneiff/kscrape/resolve/main/src/kscrape_dev/packaging/release_notes.py
10 kB
| """Build release notes from kscrape's curated changelog.""" | |
| from __future__ import annotations | |
| import argparse | |
| import re | |
| from collections.abc import Sequence | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[3] | |
| VERSION_RE = re.compile(r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$") | |
| RELEASE_HEADING_RE = re.compile( | |
| r"^# \[(?P<version>\d+\.\d+\.\d+)\] - " | |
| r"(?P<date>\d{4}-\d{2}-\d{2})$", | |
| re.MULTILINE, | |
| ) | |
| UNRELEASED_HEADING = "# [Unreleased]" | |
| UNRELEASED_SECTIONS = ( | |
| "### 💥 Breaking Change Summary", | |
| "### ➕ Added", | |
| "### 💔 Changed", | |
| "### ⚠️ Deprecated", | |
| "### 🗑️ Removed", | |
| "### 🔨 Fixed", | |
| ) | |
| def build_release_notes(markdown: str, version: str) -> str: | |
| """Extract one released changelog section for an annotated tag. | |
| :param markdown: Complete changelog text. | |
| :param version: Release version without a leading ``v``. | |
| :return: Markdown body for the release tag. | |
| """ | |
| validate_version(version) | |
| validate_unreleased_section(markdown) | |
| match = _release_heading(markdown, version) | |
| _validate_toc_entry(markdown, match) | |
| next_heading = re.search(r"^# \[", markdown[match.end() :], re.MULTILINE) | |
| section_end = ( | |
| match.end() + next_heading.start() | |
| if next_heading is not None | |
| else len(markdown) | |
| ) | |
| notes = _clean_release_markdown(markdown[match.end() : section_end]) | |
| if not any(line.lstrip().startswith("- ") for line in notes.splitlines()): | |
| raise ValueError(f"Release {version} has no changelog entries.") | |
| return f"{notes}\n" | |
| def validate_version(version: str) -> None: | |
| """Reject versions that do not use three non-zero-padded components. | |
| :param version: Candidate release version without a leading ``v``. | |
| :return: None. | |
| """ | |
| if VERSION_RE.fullmatch(version) is None: | |
| raise ValueError( | |
| f"Invalid release version {version!r}; expected MAJOR.MINOR.PATCH." | |
| ) | |
| def validate_tag_version(tag: str, project_version: str) -> None: | |
| """Require an annotated tag to name the package version exactly. | |
| :param tag: Git tag expected to use the ``vMAJOR.MINOR.PATCH`` form. | |
| :param project_version: Version declared in package metadata. | |
| :return: None. | |
| """ | |
| validate_version(project_version) | |
| expected_tag = f"v{project_version}" | |
| if tag != expected_tag: | |
| raise ValueError( | |
| f"Release tag {tag!r} does not match project version " | |
| f"{project_version!r}; expected {expected_tag!r}." | |
| ) | |
| def validate_next_version(current_version: str, next_version: str) -> None: | |
| """Require a prepared changelog release to advance package metadata. | |
| :param current_version: Version currently declared in package metadata. | |
| :param next_version: Version prepared in the changelog. | |
| :return: None. | |
| """ | |
| validate_version(current_version) | |
| validate_version(next_version) | |
| current_key = tuple(int(part) for part in current_version.split(".")) | |
| next_key = tuple(int(part) for part in next_version.split(".")) | |
| if next_key <= current_key: | |
| raise ValueError( | |
| f"Prepared release {next_version!r} must be newer than " | |
| f"the current project version {current_version!r}." | |
| ) | |
| def latest_release_version(markdown: str) -> str: | |
| """Return the highest version declared in the changelog. | |
| :param markdown: Complete changelog text. | |
| :return: Highest released semantic version. | |
| """ | |
| matches = list(RELEASE_HEADING_RE.finditer(markdown)) | |
| if not matches: | |
| raise ValueError("Changelog has no versioned release heading.") | |
| return max( | |
| (match.group("version") for match in matches), | |
| key=lambda value: tuple(int(part) for part in value.split(".")), | |
| ) | |
| def validate_unreleased_section(markdown: str) -> None: | |
| """Require a complete and empty ``[Unreleased]`` release template. | |
| :param markdown: Complete changelog text. | |
| :return: None. | |
| """ | |
| if markdown.count(UNRELEASED_HEADING) != 1: | |
| raise ValueError( | |
| "Changelog must contain exactly one [Unreleased] heading." | |
| ) | |
| start = markdown.index(UNRELEASED_HEADING) + len(UNRELEASED_HEADING) | |
| next_release = RELEASE_HEADING_RE.search(markdown, start) | |
| if next_release is None: | |
| raise ValueError( | |
| "Changelog has no released section after [Unreleased]." | |
| ) | |
| section = markdown[start : next_release.start()] | |
| for heading in UNRELEASED_SECTIONS: | |
| if section.count(heading) != 1: | |
| raise ValueError( | |
| "The [Unreleased] section must contain each release category " | |
| f"exactly once; invalid heading: {heading!r}." | |
| ) | |
| content_lines = [ | |
| line for line in _content_lines(section) if not line.startswith("### ") | |
| ] | |
| if content_lines: | |
| raise ValueError( | |
| "The [Unreleased] section still contains release entries. Move " | |
| "them into the prepared version before tagging." | |
| ) | |
| def write_release_notes(markdown: str, version: str, output: Path) -> None: | |
| """Write a released changelog section to a Markdown file. | |
| :param markdown: Complete changelog text. | |
| :param version: Release version without a leading ``v``. | |
| :param output: Destination Markdown path. | |
| :return: None. | |
| """ | |
| output.parent.mkdir(parents=True, exist_ok=True) | |
| output.write_text(build_release_notes(markdown, version), encoding="utf-8") | |
| def _release_heading(markdown: str, version: str) -> re.Match[str]: | |
| """Find the unique heading for one version. | |
| :param markdown: Complete changelog text. | |
| :param version: Release version without a leading ``v``. | |
| :return: Matched version heading. | |
| """ | |
| matches = [ | |
| match | |
| for match in RELEASE_HEADING_RE.finditer(markdown) | |
| if match.group("version") == version | |
| ] | |
| if len(matches) != 1: | |
| raise ValueError( | |
| f"Changelog must contain exactly one release heading for {version}; " | |
| f"found {len(matches)}." | |
| ) | |
| return matches[0] | |
| def _validate_toc_entry(markdown: str, match: re.Match[str]) -> None: | |
| """Require a release heading to appear once in the changelog table of contents. | |
| :param markdown: Complete changelog text. | |
| :param match: Release heading whose table-of-contents entry is required. | |
| :return: None. | |
| """ | |
| version = match.group("version") | |
| date = match.group("date") | |
| entry = f"[\\[{version}\\] - {date}](#{version.replace('.', '')}---{date})" | |
| if markdown.count(entry) != 1: | |
| raise ValueError( | |
| f"Changelog table of contents must contain exactly one {entry!r} entry." | |
| ) | |
| def _clean_release_markdown(section: str) -> str: | |
| """Remove layout markers while retaining headings and release entries. | |
| :param section: Changelog text after a version heading. | |
| :return: Compact release-note Markdown. | |
| """ | |
| cleaned: list[str] = [] | |
| previous_blank = True | |
| for line in section.splitlines(): | |
| stripped = line.strip() | |
| if _is_layout_line(stripped): | |
| continue | |
| if line.startswith("### "): | |
| line = line[1:] | |
| if not stripped: | |
| if not previous_blank: | |
| cleaned.append("") | |
| previous_blank = True | |
| continue | |
| cleaned.append(line) | |
| previous_blank = False | |
| while cleaned and not cleaned[-1]: | |
| cleaned.pop() | |
| return "\n".join(cleaned) | |
| def _content_lines(section: str) -> list[str]: | |
| """Return non-empty changelog lines that are not layout markers. | |
| :param section: One changelog section. | |
| :return: Content lines stripped of surrounding whitespace. | |
| """ | |
| return [ | |
| line.strip() | |
| for line in section.splitlines() | |
| if line.strip() and not _is_layout_line(line.strip()) | |
| ] | |
| def _is_layout_line(line: str) -> bool: | |
| """Identify a Markdown line used only for changelog spacing. | |
| :param line: Stripped Markdown line. | |
| :return: Whether the line contains no release content. | |
| """ | |
| return ( | |
| line == "<br>" | |
| or line == "---" | |
| or (line.startswith("<!--") and line.endswith("-->")) | |
| ) | |
| def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: | |
| """Parse release-note extraction arguments. | |
| :param argv: Optional argument vector used by tests. | |
| :return: Parsed command arguments. | |
| """ | |
| parser = argparse.ArgumentParser( | |
| description="Extract one release body from CHANGELOG.md." | |
| ) | |
| parser.add_argument("version", help="Release version without a leading v.") | |
| parser.add_argument( | |
| "--changelog", | |
| type=Path, | |
| default=ROOT / "CHANGELOG.md", | |
| help="Changelog source path.", | |
| ) | |
| parser.add_argument( | |
| "--output", type=Path, required=True, help="Destination path." | |
| ) | |
| parser.add_argument("--tag", help="Optional tag to validate.") | |
| parser.add_argument( | |
| "--project-version", help="Optional package version to validate." | |
| ) | |
| return parser.parse_args(argv) | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| """Validate release state and write a release-note file. | |
| :param argv: Optional argument vector used by tests. | |
| :return: Process exit code. | |
| """ | |
| args = parse_args(argv) | |
| if (args.tag is None) != (args.project_version is None): | |
| raise ValueError( | |
| "--tag and --project-version must be provided together." | |
| ) | |
| if args.tag is not None: | |
| validate_tag_version(args.tag, args.project_version) | |
| if args.version != args.project_version: | |
| raise ValueError( | |
| f"Requested release version {args.version!r} does not match " | |
| f"project version {args.project_version!r}." | |
| ) | |
| write_release_notes( | |
| args.changelog.read_text(encoding="utf-8"), args.version, args.output | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |