blob: adea8bd4bcf9afe541b592d87569a309fe4fb04f [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").
Gilles Peskineafc9db82020-01-30 11:38:01 +010087 # Refuse ".x" in a version number where x is a letter: this indicates
88 # a version that is not yet released. Something like "3.1a" is accepted.
89 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
90 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
Gilles Peskinea2607962020-01-28 19:58:17 +010091
92 def section_is_released_version(self, title):
93 """Whether this section is for a released version.
94
95 True if the given level-2 section title indicates that this section
96 contains released changes, otherwise False.
97 """
98 # Assume that a released version has a numerical version number
99 # that follows a particular pattern. These criteria may be revised
100 # as needed in future versions of this script.
101 version_number = re.search(self._version_number_re, title)
Gilles Peskineafc9db82020-01-30 11:38:01 +0100102 if version_number:
103 return not re.search(self._incomplete_version_number_re,
104 version_number.group(0))
105 else:
106 return False
Gilles Peskinea2607962020-01-28 19:58:17 +0100107
108 def unreleased_version_title(self):
109 """The title to use if creating a new section for an unreleased version."""
110 # pylint: disable=no-self-use; this method may be overridden
111 return b'Unreleased changes'
112
Gilles Peskine40b3f412019-10-13 21:44:25 +0200113 def __init__(self, input_stream):
114 """Create a changelog object.
115
Gilles Peskine974232f2020-01-22 12:43:29 +0100116 Populate the changelog object from the content of the file
117 input_stream. This is typically a file opened for reading, but
118 can be any generator returning the lines to read.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200119 """
Gilles Peskine37d670a2020-01-28 19:14:15 +0100120 # Content before the level-2 section where the new entries are to be
121 # added.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200122 self.header = []
Gilles Peskine37d670a2020-01-28 19:14:15 +0100123 # Content of the level-3 sections of where the new entries are to
124 # be added.
Gilles Peskined8b6c772020-01-28 18:57:47 +0100125 self.section_content = OrderedDict()
126 for section in STANDARD_SECTIONS:
127 self.section_content[section] = []
Gilles Peskine37d670a2020-01-28 19:14:15 +0100128 # Content of level-2 sections for already-released versions.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200129 self.trailer = []
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100130 self.read_main_file(input_stream)
131
132 def read_main_file(self, input_stream):
133 """Populate the changelog object from the content of the file.
134
135 This method is only intended to be called as part of the constructor
136 of the class and may not act sensibly on an object that is already
137 partially populated.
138 """
Gilles Peskinea2607962020-01-28 19:58:17 +0100139 # Parse the first level-2 section, containing changelog entries
140 # for unreleased changes.
141 # If we'll be expanding this section, everything before the first
Gilles Peskine37d670a2020-01-28 19:14:15 +0100142 # level-3 section title ("###...") following the first level-2
143 # section title ("##...") is passed through as the header
144 # and everything after the second level-2 section title is passed
145 # through as the trailer. Inside the first level-2 section,
146 # split out the level-3 sections.
Gilles Peskinea2607962020-01-28 19:58:17 +0100147 # If we'll be creating a new version, the header is everything
148 # before the point where we want to add the level-2 section
149 # for this version, and the trailer is what follows.
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100150 level_2_seen = 0
151 current_section = None
Gilles Peskine40b3f412019-10-13 21:44:25 +0200152 for line in input_stream:
153 level, content = self.title_level(line)
154 if level == 2:
155 level_2_seen += 1
Gilles Peskinea2607962020-01-28 19:58:17 +0100156 if level_2_seen == 1:
157 if self.section_is_released_version(content):
158 self.header.append(b'## ' +
159 self.unreleased_version_title() +
160 b'\n\n')
161 level_2_seen = 2
Gilles Peskine40b3f412019-10-13 21:44:25 +0200162 elif level == 3 and level_2_seen == 1:
163 current_section = content
Gilles Peskined8b6c772020-01-28 18:57:47 +0100164 self.section_content.setdefault(content, [])
Gilles Peskine37d670a2020-01-28 19:14:15 +0100165 if level_2_seen == 1 and current_section is not None:
166 if level != 3 and line.strip():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200167 self.section_content[current_section].append(line)
168 elif level_2_seen <= 1:
169 self.header.append(line)
170 else:
171 self.trailer.append(line)
172
173 def add_file(self, input_stream):
174 """Add changelog entries from a file.
175
176 Read lines from input_stream, which is typically a file opened
177 for reading. These lines must contain a series of level 3
178 Markdown sections with recognized titles. The corresponding
179 content is injected into the respective sections in the changelog.
180 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100181 in STANDARD_SECTIONS in assemble_changelog.py or already present
182 in ChangeLog.md. Section titles must match byte-for-byte except that
183 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200184 """
185 filename = input_stream.name
186 current_section = None
187 for line_number, line in enumerate(input_stream, 1):
188 if not line.strip():
189 continue
190 level, content = self.title_level(line)
191 if level == 3:
192 current_section = content
193 if current_section not in self.section_content:
194 raise InputFormatError(filename, line_number,
195 'Section {} is not recognized',
196 str(current_section)[1:])
197 elif level == 0:
198 if current_section is None:
199 raise InputFormatError(filename, line_number,
200 'Missing section title at the beginning of the file')
201 self.section_content[current_section].append(line)
202 else:
203 raise InputFormatError(filename, line_number,
204 'Only level 3 headers (###) are permitted')
205
206 def write(self, filename):
207 """Write the changelog to the specified file.
208 """
209 with open(filename, 'wb') as out:
210 for line in self.header:
211 out.write(line)
Gilles Peskined8b6c772020-01-28 18:57:47 +0100212 for section, lines in self.section_content.items():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200213 if not lines:
214 continue
215 out.write(b'### ' + section + b'\n\n')
216 for line in lines:
217 out.write(line)
218 out.write(b'\n')
219 for line in self.trailer:
220 out.write(line)
221
Gilles Peskine2b242492020-01-22 15:41:50 +0100222def check_output(generated_output_file, main_input_file, merged_files):
223 """Make sanity checks on the generated output.
224
225 The intent of these sanity checks is to have reasonable confidence
226 that no content has been lost.
227
228 The sanity check is that every line that is present in an input file
229 is also present in an output file. This is not perfect but good enough
230 for now.
231 """
232 generated_output = set(open(generated_output_file, 'rb'))
233 for line in open(main_input_file, 'rb'):
234 if line not in generated_output:
235 raise LostContent('original file', line)
236 for merged_file in merged_files:
237 for line in open(merged_file, 'rb'):
238 if line not in generated_output:
239 raise LostContent(merged_file, line)
240
241def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200242 """Write the changelog to the output file.
243
Gilles Peskine2b242492020-01-22 15:41:50 +0100244 The input file and the list of merged files are used only for sanity
245 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200246 """
247 if os.path.exists(output_file) and not os.path.isfile(output_file):
248 # The output is a non-regular file (e.g. pipe). Write to it directly.
249 output_temp = output_file
250 else:
251 # The output is a regular file. Write to a temporary file,
252 # then move it into place atomically.
253 output_temp = output_file + '.tmp'
254 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100255 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200256 if output_temp != output_file:
257 os.rename(output_temp, output_file)
258
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100259def remove_merged_entries(files_to_remove):
260 for filename in files_to_remove:
261 os.remove(filename)
262
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100263def list_files_to_merge(options):
264 """List the entry files to merge, oldest first.
265
266 A file is considered older if it was merged earlier. See
267 `FileMergeTimestamp` for details.
268 """
269 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
270 return files_to_merge
271
Gilles Peskine40b3f412019-10-13 21:44:25 +0200272def merge_entries(options):
273 """Merge changelog entries into the changelog file.
274
275 Read the changelog file from options.input.
276 Read entries to merge from the directory options.dir.
277 Write the new changelog to options.output.
278 Remove the merged entries if options.keep_entries is false.
279 """
280 with open(options.input, 'rb') as input_file:
281 changelog = ChangeLog(input_file)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100282 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200283 if not files_to_merge:
284 sys.stderr.write('There are no pending changelog entries.\n')
285 return
286 for filename in files_to_merge:
287 with open(filename, 'rb') as input_file:
288 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100289 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100290 if not options.keep_entries:
291 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200292
293def set_defaults(options):
294 """Add default values for missing options."""
295 output_file = getattr(options, 'output', None)
296 if output_file is None:
297 options.output = options.input
298 if getattr(options, 'keep_entries', None) is None:
299 options.keep_entries = (output_file is not None)
300
301def main():
302 """Command line entry point."""
303 parser = argparse.ArgumentParser(description=__doc__)
304 parser.add_argument('--dir', '-d', metavar='DIR',
305 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100306 help='Directory to read entries from'
307 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200308 parser.add_argument('--input', '-i', metavar='FILE',
309 default='ChangeLog.md',
Gilles Peskine6e910092020-01-22 15:58:18 +0100310 help='Existing changelog file to read from and augment'
311 ' (default: ChangeLog.md)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200312 parser.add_argument('--keep-entries',
313 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100314 help='Keep the files containing entries'
315 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200316 parser.add_argument('--no-keep-entries',
317 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100318 help='Remove the files containing entries after they are merged'
319 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200320 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100321 help='Output changelog file'
322 ' (default: overwrite the input)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200323 options = parser.parse_args()
324 set_defaults(options)
325 merge_entries(options)
326
327if __name__ == '__main__':
328 main()