# Copyright (C) 2024 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only """ This tool reads all the changelogs in doc/changelogs and generates .rst files for each of the changelogs. This .rst files are then used to generate the contents of the 'Release Notes' section in the navigation pane of the Qt for Python documentation. """ import re import logging import shutil from pathlib import Path from argparse import ArgumentParser, RawTextHelpFormatter SECTION_NAMES = ["PySide6", "Shiboken6", "PySide2", "Shiboken2"] DIR = Path(__file__).parent DEFAULT_OUTPUT_DIR = Path(f"{DIR}/../../sources/pyside6/doc/release_notes").resolve() CHANGELOG_DIR = Path(f"{DIR}/../../doc/changelogs").resolve() BASE_CONTENT = """\ .. _release_notes: Release Notes ============= This section contains the release notes for different versions of Qt for Python. .. toctree:: :maxdepth: 1 pyside6_release_notes.md shiboken6_release_notes.md pyside2_release_notes.md shiboken2_release_notes.md """ JIRA_URL = "https://qt-project.atlassian.net/browse/" class Changelog: # for matching lines like * PySide6 * to identify the section section_pattern = re.compile(r"\* +(\w+) +\*") # for line that start with ' -' which lists the changes line_pattern = re.compile(r"^ -") # for line that contains a bug report like PYSIDE- bug_number_pattern = re.compile(r"\[PYSIDE-\d+\]") # version from changelog file name version_pattern = re.compile(r"changes-(\d+)\.(\d+)\.(\d+)") def __init__(self, file_path: Path): self.file_path = file_path version_match = self.version_pattern.match(file_path.name) assert (version_match) self.version = (int(version_match.group(1)), int(version_match.group(2)), int(version_match.group(3))) self.sections = {section: [] for section in SECTION_NAMES} def add_line(self, section, line): self.sections[section].append(line) def parsed_sections(self): return self.sections def parse(self): current_section = None buffer = [] with open(self.file_path, 'r', encoding='utf-8') as file: # convert the lines to an iterator for skip the '***' lines lines = iter(file.readlines()) for line in lines: # skip lines with all characters as '*' if line.strip() == '*' * len(line.strip()): continue match = self.section_pattern.match(line) if match: # if buffer has content, add it to the current section if buffer: self.add_line(current_section, ' '.join(buffer).strip()) buffer = [] current_section = match.group(1) # skip the next line which contains '***' try: next(lines) except StopIteration: break continue if current_section: if self.line_pattern.match(line) and buffer: self.add_line(current_section, ' '.join(buffer).strip()) buffer = [] # If the line contains a reference to a bug report like [PYSIDE-] # then insert a link to the reference that conforms with Sphinx syntax bug_number = self.bug_number_pattern.search(line) if bug_number: bug_number = bug_number.group() # remove the square brackets actual_bug_number = bug_number[1:-1] bug_number_replacement = ( f"[{actual_bug_number}]({JIRA_URL}/{actual_bug_number})" ) line = re.sub(re.escape(bug_number), bug_number_replacement, line) # Add the line to the buffer buffer.append(line.strip()) # Add any remaining content in the buffer to the current section if buffer: self.add_line(current_section, ' '.join(buffer).strip()) def parse_changelogs() -> str: ''' Parse the changelogs in the CHANGELOG_DIR and return a list of parsed changelogs. ''' changelogs = [] logging.info(f"[RELEASE_DOC] Processing changelogs in {CHANGELOG_DIR}") for file_path in CHANGELOG_DIR.iterdir(): # exclude changes-1.2.3 if "changes-1.2.3" in file_path.name: continue logging.info(f"[RELEASE_DOC] Processing file {file_path.name}") changelog = Changelog(file_path) changelog.parse() changelogs.append(changelog) return changelogs def write_md_file(section: str, changelogs: list[Changelog], output_dir: Path): ''' For each section create a .md file with the following content: Section Name ============ Version ------- - Change 1 - Change 2 .... ''' file_path = output_dir / f"{section.lower()}_release_notes.md" with open(file_path, 'w', encoding='utf-8') as file: file.write(f"# {section}\n") for changelog in changelogs: section_contents = changelog.parsed_sections()[section] if section_contents: v = changelog.version file.write(f"## {v[0]}.{v[1]}.{v[2]}\n\n") for lines in section_contents: # separate each line with a newline file.write(f"{lines}\n") file.write("\n") def generate_index_file(output_dir: Path): """Generate the index RST file.""" index_path = output_dir / "index.rst" index_path.write_text(BASE_CONTENT, encoding='utf-8') def main(): parser = ArgumentParser(description="Generate release notes from changelog", formatter_class=RawTextHelpFormatter) parser.add_argument("-v", "--verbose", help="run in verbose mode", action="store_const", dest="loglevel", const=logging.INFO) parser.add_argument("--target", "-t", help="Directory to output the generated files", type=Path, default=DEFAULT_OUTPUT_DIR) args = parser.parse_args() logging.basicConfig(level=args.loglevel) output_dir = args.target.resolve() # create the output directory if it does not exist # otherwise remove its contents if output_dir.is_dir(): shutil.rmtree(output_dir, ignore_errors=True) logging.info(f"[RELEASE_DOC] Removed existing {output_dir}") logging.info(f"[RELEASE_DOC] Creating {output_dir}") output_dir.mkdir(exist_ok=True) logging.info("[RELEASE_DOC] Generating index.md file") generate_index_file(output_dir) logging.info("[RELEASE_DOC] Parsing changelogs") changelogs = parse_changelogs() # sort changelogs by version number in descending order changelogs.sort(key=lambda x: x.version, reverse=True) for section in SECTION_NAMES: logging.info(f"[RELEASE_DOC] Generating {section.lower()}_release_notes.md file") write_md_file(section, changelogs, output_dir) if __name__ == "__main__": main()