aboutsummaryrefslogtreecommitdiffstats
path: root/tools/release_notes/main.py
blob: 77ce4742072f662d704fc351d9c502953f2afeba (plain)
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
# 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
"""


class Changelog:
    def __init__(self, file_path: Path):
        self.file_path = file_path
        self.version = file_path.name.split("-")[-1]
        self.sections = {section: [] for section in SECTION_NAMES}
        # for matching lines like *    PySide6    * to identify the section
        self.section_pattern = re.compile(r"\* +(\w+) +\*")
        # for line that start with ' -' which lists the changes
        self.line_pattern = re.compile(r"^ -")
        # for line that contains a bug report like PYSIDE-<bug_number>
        self.bug_number_pattern = re.compile(r"\[PYSIDE-\d+\]")

    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-<bug_number>]
                # 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}]"
                        f"(https://bugreports.qt.io/browse/{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:
                file.write(f"## {changelog.version}\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()