blob: 04a5d6c39e7afd9b51edd3034b2f7127b7c2033c [file] [log] [blame]
Gilles Peskine40b3f412019-10-13 21:44:25 +02001#!/usr/bin/env python3
2
3"""Assemble Mbed Crypto change log entries into the change log file.
Gilles Peskinea2607962020-01-28 19:58:17 +01004
5Add changelog entries to the first level-2 section.
6Create a new level-2 section for unreleased changes if needed.
7Remove the input files unless --keep-entries is specified.
Gilles Peskine40b3f412019-10-13 21:44:25 +02008"""
9
10# Copyright (C) 2019, Arm Limited, All Rights Reserved
11# SPDX-License-Identifier: Apache-2.0
12#
13# Licensed under the Apache License, Version 2.0 (the "License"); you may
14# not use this file except in compliance with the License.
15# You may obtain a copy of the License at
16#
17# http://www.apache.org/licenses/LICENSE-2.0
18#
19# Unless required by applicable law or agreed to in writing, software
20# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
21# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22# See the License for the specific language governing permissions and
23# limitations under the License.
24#
25# This file is part of Mbed Crypto (https://tls.mbed.org)
26
27import argparse
Gilles Peskined8b6c772020-01-28 18:57:47 +010028from collections import OrderedDict
Gilles Peskine40b3f412019-10-13 21:44:25 +020029import glob
30import os
31import re
32import sys
33
34class InputFormatError(Exception):
35 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010036 message = '{}:{}: {}'.format(filename, line_number,
37 message.format(*args, **kwargs))
38 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020039
Gilles Peskine2b242492020-01-22 15:41:50 +010040class LostContent(Exception):
41 def __init__(self, filename, line):
42 message = ('Lost content from {}: "{}"'.format(filename, line))
43 super().__init__(message)
44
Gilles Peskine40b3f412019-10-13 21:44:25 +020045STANDARD_SECTIONS = (
46 b'Interface changes',
47 b'Default behavior changes',
48 b'Requirement changes',
49 b'New deprecations',
50 b'Removals',
51 b'New features',
52 b'Security',
53 b'Bug fixes',
54 b'Performance improvements',
55 b'Other changes',
56)
57
58class ChangeLog:
59 """An Mbed Crypto changelog.
60
61 A changelog is a file in Markdown format. Each level 2 section title
62 starts a version, and versions are sorted in reverse chronological
63 order. Lines with a level 2 section title must start with '##'.
64
65 Within a version, there are multiple sections, each devoted to a kind
66 of change: bug fix, feature request, etc. Section titles should match
67 entries in STANDARD_SECTIONS exactly.
68
69 Within each section, each separate change should be on a line starting
70 with a '*' bullet. There may be blank lines surrounding titles, but
71 there should not be any blank line inside a section.
72 """
73
74 _title_re = re.compile(br'#*')
75 def title_level(self, line):
76 """Determine whether the line is a title.
77
78 Return (level, content) where level is the Markdown section level
79 (1 for '#', 2 for '##', etc.) and content is the section title
80 without leading or trailing whitespace. For a non-title line,
81 the level is 0.
82 """
83 level = re.match(self._title_re, line).end()
84 return level, line[level:].strip()
85
Gilles Peskinea2607962020-01-28 19:58:17 +010086 # Only accept dotted version numbers (e.g. "3.1", not "3").
87 # Refuse ".x" in a version number: this indicates a version that is
88 # not yet released.
89 _version_number_re = re.compile(br'[0-9]\.[0-9][0-9.]+([^.]|\.[^0-9x])')
90
91 def section_is_released_version(self, title):
92 """Whether this section is for a released version.
93
94 True if the given level-2 section title indicates that this section
95 contains released changes, otherwise False.
96 """
97 # Assume that a released version has a numerical version number
98 # that follows a particular pattern. These criteria may be revised
99 # as needed in future versions of this script.
100 version_number = re.search(self._version_number_re, title)
101 return bool(version_number)
102
103 def unreleased_version_title(self):
104 """The title to use if creating a new section for an unreleased version."""
105 # pylint: disable=no-self-use; this method may be overridden
106 return b'Unreleased changes'
107
Gilles Peskine40b3f412019-10-13 21:44:25 +0200108 def __init__(self, input_stream):
109 """Create a changelog object.
110
Gilles Peskine974232f2020-01-22 12:43:29 +0100111 Populate the changelog object from the content of the file
112 input_stream. This is typically a file opened for reading, but
113 can be any generator returning the lines to read.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200114 """
Gilles Peskine37d670a2020-01-28 19:14:15 +0100115 # Content before the level-2 section where the new entries are to be
116 # added.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200117 self.header = []
Gilles Peskine37d670a2020-01-28 19:14:15 +0100118 # Content of the level-3 sections of where the new entries are to
119 # be added.
Gilles Peskined8b6c772020-01-28 18:57:47 +0100120 self.section_content = OrderedDict()
121 for section in STANDARD_SECTIONS:
122 self.section_content[section] = []
Gilles Peskine37d670a2020-01-28 19:14:15 +0100123 # Content of level-2 sections for already-released versions.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200124 self.trailer = []
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100125 self.read_main_file(input_stream)
126
127 def read_main_file(self, input_stream):
128 """Populate the changelog object from the content of the file.
129
130 This method is only intended to be called as part of the constructor
131 of the class and may not act sensibly on an object that is already
132 partially populated.
133 """
Gilles Peskinea2607962020-01-28 19:58:17 +0100134 # Parse the first level-2 section, containing changelog entries
135 # for unreleased changes.
136 # If we'll be expanding this section, everything before the first
Gilles Peskine37d670a2020-01-28 19:14:15 +0100137 # level-3 section title ("###...") following the first level-2
138 # section title ("##...") is passed through as the header
139 # and everything after the second level-2 section title is passed
140 # through as the trailer. Inside the first level-2 section,
141 # split out the level-3 sections.
Gilles Peskinea2607962020-01-28 19:58:17 +0100142 # If we'll be creating a new version, the header is everything
143 # before the point where we want to add the level-2 section
144 # for this version, and the trailer is what follows.
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100145 level_2_seen = 0
146 current_section = None
Gilles Peskine40b3f412019-10-13 21:44:25 +0200147 for line in input_stream:
148 level, content = self.title_level(line)
149 if level == 2:
150 level_2_seen += 1
Gilles Peskinea2607962020-01-28 19:58:17 +0100151 if level_2_seen == 1:
152 if self.section_is_released_version(content):
153 self.header.append(b'## ' +
154 self.unreleased_version_title() +
155 b'\n\n')
156 level_2_seen = 2
Gilles Peskine40b3f412019-10-13 21:44:25 +0200157 elif level == 3 and level_2_seen == 1:
158 current_section = content
Gilles Peskined8b6c772020-01-28 18:57:47 +0100159 self.section_content.setdefault(content, [])
Gilles Peskine37d670a2020-01-28 19:14:15 +0100160 if level_2_seen == 1 and current_section is not None:
161 if level != 3 and line.strip():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200162 self.section_content[current_section].append(line)
163 elif level_2_seen <= 1:
164 self.header.append(line)
165 else:
166 self.trailer.append(line)
167
168 def add_file(self, input_stream):
169 """Add changelog entries from a file.
170
171 Read lines from input_stream, which is typically a file opened
172 for reading. These lines must contain a series of level 3
173 Markdown sections with recognized titles. The corresponding
174 content is injected into the respective sections in the changelog.
175 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100176 in STANDARD_SECTIONS in assemble_changelog.py or already present
177 in ChangeLog.md. Section titles must match byte-for-byte except that
178 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200179 """
180 filename = input_stream.name
181 current_section = None
182 for line_number, line in enumerate(input_stream, 1):
183 if not line.strip():
184 continue
185 level, content = self.title_level(line)
186 if level == 3:
187 current_section = content
188 if current_section not in self.section_content:
189 raise InputFormatError(filename, line_number,
190 'Section {} is not recognized',
191 str(current_section)[1:])
192 elif level == 0:
193 if current_section is None:
194 raise InputFormatError(filename, line_number,
195 'Missing section title at the beginning of the file')
196 self.section_content[current_section].append(line)
197 else:
198 raise InputFormatError(filename, line_number,
199 'Only level 3 headers (###) are permitted')
200
201 def write(self, filename):
202 """Write the changelog to the specified file.
203 """
204 with open(filename, 'wb') as out:
205 for line in self.header:
206 out.write(line)
Gilles Peskined8b6c772020-01-28 18:57:47 +0100207 for section, lines in self.section_content.items():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200208 if not lines:
209 continue
210 out.write(b'### ' + section + b'\n\n')
211 for line in lines:
212 out.write(line)
213 out.write(b'\n')
214 for line in self.trailer:
215 out.write(line)
216
Gilles Peskine2b242492020-01-22 15:41:50 +0100217def check_output(generated_output_file, main_input_file, merged_files):
218 """Make sanity checks on the generated output.
219
220 The intent of these sanity checks is to have reasonable confidence
221 that no content has been lost.
222
223 The sanity check is that every line that is present in an input file
224 is also present in an output file. This is not perfect but good enough
225 for now.
226 """
227 generated_output = set(open(generated_output_file, 'rb'))
228 for line in open(main_input_file, 'rb'):
229 if line not in generated_output:
230 raise LostContent('original file', line)
231 for merged_file in merged_files:
232 for line in open(merged_file, 'rb'):
233 if line not in generated_output:
234 raise LostContent(merged_file, line)
235
236def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200237 """Write the changelog to the output file.
238
Gilles Peskine2b242492020-01-22 15:41:50 +0100239 The input file and the list of merged files are used only for sanity
240 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200241 """
242 if os.path.exists(output_file) and not os.path.isfile(output_file):
243 # The output is a non-regular file (e.g. pipe). Write to it directly.
244 output_temp = output_file
245 else:
246 # The output is a regular file. Write to a temporary file,
247 # then move it into place atomically.
248 output_temp = output_file + '.tmp'
249 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100250 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200251 if output_temp != output_file:
252 os.rename(output_temp, output_file)
253
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100254def remove_merged_entries(files_to_remove):
255 for filename in files_to_remove:
256 os.remove(filename)
257
Gilles Peskine40b3f412019-10-13 21:44:25 +0200258def merge_entries(options):
259 """Merge changelog entries into the changelog file.
260
261 Read the changelog file from options.input.
262 Read entries to merge from the directory options.dir.
263 Write the new changelog to options.output.
264 Remove the merged entries if options.keep_entries is false.
265 """
266 with open(options.input, 'rb') as input_file:
267 changelog = ChangeLog(input_file)
268 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
269 if not files_to_merge:
270 sys.stderr.write('There are no pending changelog entries.\n')
271 return
272 for filename in files_to_merge:
273 with open(filename, 'rb') as input_file:
274 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100275 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100276 if not options.keep_entries:
277 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200278
279def set_defaults(options):
280 """Add default values for missing options."""
281 output_file = getattr(options, 'output', None)
282 if output_file is None:
283 options.output = options.input
284 if getattr(options, 'keep_entries', None) is None:
285 options.keep_entries = (output_file is not None)
286
287def main():
288 """Command line entry point."""
289 parser = argparse.ArgumentParser(description=__doc__)
290 parser.add_argument('--dir', '-d', metavar='DIR',
291 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100292 help='Directory to read entries from'
293 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200294 parser.add_argument('--input', '-i', metavar='FILE',
295 default='ChangeLog.md',
Gilles Peskine6e910092020-01-22 15:58:18 +0100296 help='Existing changelog file to read from and augment'
297 ' (default: ChangeLog.md)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200298 parser.add_argument('--keep-entries',
299 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100300 help='Keep the files containing entries'
301 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200302 parser.add_argument('--no-keep-entries',
303 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100304 help='Remove the files containing entries after they are merged'
305 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200306 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100307 help='Output changelog file'
308 ' (default: overwrite the input)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200309 options = parser.parse_args()
310 set_defaults(options)
311 merge_entries(options)
312
313if __name__ == '__main__':
314 main()