blob: 1d4755708c59f124a4b2f245dbf04a5ea4171e98 [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.
4"""
5
6# Copyright (C) 2019, Arm Limited, All Rights Reserved
7# SPDX-License-Identifier: Apache-2.0
8#
9# Licensed under the Apache License, Version 2.0 (the "License"); you may
10# not use this file except in compliance with the License.
11# You may obtain a copy of the License at
12#
13# http://www.apache.org/licenses/LICENSE-2.0
14#
15# Unless required by applicable law or agreed to in writing, software
16# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
17# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18# See the License for the specific language governing permissions and
19# limitations under the License.
20#
21# This file is part of Mbed Crypto (https://tls.mbed.org)
22
23import argparse
Gilles Peskined8b6c772020-01-28 18:57:47 +010024from collections import OrderedDict
Gilles Peskine40b3f412019-10-13 21:44:25 +020025import glob
26import os
27import re
28import sys
29
30class InputFormatError(Exception):
31 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010032 message = '{}:{}: {}'.format(filename, line_number,
33 message.format(*args, **kwargs))
34 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020035
Gilles Peskine2b242492020-01-22 15:41:50 +010036class LostContent(Exception):
37 def __init__(self, filename, line):
38 message = ('Lost content from {}: "{}"'.format(filename, line))
39 super().__init__(message)
40
Gilles Peskine40b3f412019-10-13 21:44:25 +020041STANDARD_SECTIONS = (
42 b'Interface changes',
43 b'Default behavior changes',
44 b'Requirement changes',
45 b'New deprecations',
46 b'Removals',
47 b'New features',
48 b'Security',
49 b'Bug fixes',
50 b'Performance improvements',
51 b'Other changes',
52)
53
54class ChangeLog:
55 """An Mbed Crypto changelog.
56
57 A changelog is a file in Markdown format. Each level 2 section title
58 starts a version, and versions are sorted in reverse chronological
59 order. Lines with a level 2 section title must start with '##'.
60
61 Within a version, there are multiple sections, each devoted to a kind
62 of change: bug fix, feature request, etc. Section titles should match
63 entries in STANDARD_SECTIONS exactly.
64
65 Within each section, each separate change should be on a line starting
66 with a '*' bullet. There may be blank lines surrounding titles, but
67 there should not be any blank line inside a section.
68 """
69
70 _title_re = re.compile(br'#*')
71 def title_level(self, line):
72 """Determine whether the line is a title.
73
74 Return (level, content) where level is the Markdown section level
75 (1 for '#', 2 for '##', etc.) and content is the section title
76 without leading or trailing whitespace. For a non-title line,
77 the level is 0.
78 """
79 level = re.match(self._title_re, line).end()
80 return level, line[level:].strip()
81
Gilles Peskine40b3f412019-10-13 21:44:25 +020082 def __init__(self, input_stream):
83 """Create a changelog object.
84
Gilles Peskine974232f2020-01-22 12:43:29 +010085 Populate the changelog object from the content of the file
86 input_stream. This is typically a file opened for reading, but
87 can be any generator returning the lines to read.
Gilles Peskine40b3f412019-10-13 21:44:25 +020088 """
Gilles Peskine37d670a2020-01-28 19:14:15 +010089 # Content before the level-2 section where the new entries are to be
90 # added.
Gilles Peskine40b3f412019-10-13 21:44:25 +020091 self.header = []
Gilles Peskine37d670a2020-01-28 19:14:15 +010092 # Content of the level-3 sections of where the new entries are to
93 # be added.
Gilles Peskined8b6c772020-01-28 18:57:47 +010094 self.section_content = OrderedDict()
95 for section in STANDARD_SECTIONS:
96 self.section_content[section] = []
Gilles Peskine37d670a2020-01-28 19:14:15 +010097 # Content of level-2 sections for already-released versions.
Gilles Peskine40b3f412019-10-13 21:44:25 +020098 self.trailer = []
Gilles Peskine8c4a84c2020-01-22 15:40:39 +010099 self.read_main_file(input_stream)
100
101 def read_main_file(self, input_stream):
102 """Populate the changelog object from the content of the file.
103
104 This method is only intended to be called as part of the constructor
105 of the class and may not act sensibly on an object that is already
106 partially populated.
107 """
Gilles Peskine37d670a2020-01-28 19:14:15 +0100108 # Parse the first level-2 section. Everything before the first
109 # level-3 section title ("###...") following the first level-2
110 # section title ("##...") is passed through as the header
111 # and everything after the second level-2 section title is passed
112 # through as the trailer. Inside the first level-2 section,
113 # split out the level-3 sections.
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100114 level_2_seen = 0
115 current_section = None
Gilles Peskine40b3f412019-10-13 21:44:25 +0200116 for line in input_stream:
117 level, content = self.title_level(line)
118 if level == 2:
119 level_2_seen += 1
Gilles Peskine40b3f412019-10-13 21:44:25 +0200120 elif level == 3 and level_2_seen == 1:
121 current_section = content
Gilles Peskined8b6c772020-01-28 18:57:47 +0100122 self.section_content.setdefault(content, [])
Gilles Peskine37d670a2020-01-28 19:14:15 +0100123 if level_2_seen == 1 and current_section is not None:
124 if level != 3 and line.strip():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200125 self.section_content[current_section].append(line)
126 elif level_2_seen <= 1:
127 self.header.append(line)
128 else:
129 self.trailer.append(line)
130
131 def add_file(self, input_stream):
132 """Add changelog entries from a file.
133
134 Read lines from input_stream, which is typically a file opened
135 for reading. These lines must contain a series of level 3
136 Markdown sections with recognized titles. The corresponding
137 content is injected into the respective sections in the changelog.
138 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100139 in STANDARD_SECTIONS in assemble_changelog.py or already present
140 in ChangeLog.md. Section titles must match byte-for-byte except that
141 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200142 """
143 filename = input_stream.name
144 current_section = None
145 for line_number, line in enumerate(input_stream, 1):
146 if not line.strip():
147 continue
148 level, content = self.title_level(line)
149 if level == 3:
150 current_section = content
151 if current_section not in self.section_content:
152 raise InputFormatError(filename, line_number,
153 'Section {} is not recognized',
154 str(current_section)[1:])
155 elif level == 0:
156 if current_section is None:
157 raise InputFormatError(filename, line_number,
158 'Missing section title at the beginning of the file')
159 self.section_content[current_section].append(line)
160 else:
161 raise InputFormatError(filename, line_number,
162 'Only level 3 headers (###) are permitted')
163
164 def write(self, filename):
165 """Write the changelog to the specified file.
166 """
167 with open(filename, 'wb') as out:
168 for line in self.header:
169 out.write(line)
Gilles Peskined8b6c772020-01-28 18:57:47 +0100170 for section, lines in self.section_content.items():
Gilles Peskine40b3f412019-10-13 21:44:25 +0200171 while lines and not lines[0].strip():
172 del lines[0]
173 while lines and not lines[-1].strip():
174 del lines[-1]
175 if not lines:
176 continue
177 out.write(b'### ' + section + b'\n\n')
178 for line in lines:
179 out.write(line)
180 out.write(b'\n')
181 for line in self.trailer:
182 out.write(line)
183
Gilles Peskine2b242492020-01-22 15:41:50 +0100184def check_output(generated_output_file, main_input_file, merged_files):
185 """Make sanity checks on the generated output.
186
187 The intent of these sanity checks is to have reasonable confidence
188 that no content has been lost.
189
190 The sanity check is that every line that is present in an input file
191 is also present in an output file. This is not perfect but good enough
192 for now.
193 """
194 generated_output = set(open(generated_output_file, 'rb'))
195 for line in open(main_input_file, 'rb'):
196 if line not in generated_output:
197 raise LostContent('original file', line)
198 for merged_file in merged_files:
199 for line in open(merged_file, 'rb'):
200 if line not in generated_output:
201 raise LostContent(merged_file, line)
202
203def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200204 """Write the changelog to the output file.
205
Gilles Peskine2b242492020-01-22 15:41:50 +0100206 The input file and the list of merged files are used only for sanity
207 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200208 """
209 if os.path.exists(output_file) and not os.path.isfile(output_file):
210 # The output is a non-regular file (e.g. pipe). Write to it directly.
211 output_temp = output_file
212 else:
213 # The output is a regular file. Write to a temporary file,
214 # then move it into place atomically.
215 output_temp = output_file + '.tmp'
216 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100217 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200218 if output_temp != output_file:
219 os.rename(output_temp, output_file)
220
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100221def remove_merged_entries(files_to_remove):
222 for filename in files_to_remove:
223 os.remove(filename)
224
Gilles Peskine40b3f412019-10-13 21:44:25 +0200225def merge_entries(options):
226 """Merge changelog entries into the changelog file.
227
228 Read the changelog file from options.input.
229 Read entries to merge from the directory options.dir.
230 Write the new changelog to options.output.
231 Remove the merged entries if options.keep_entries is false.
232 """
233 with open(options.input, 'rb') as input_file:
234 changelog = ChangeLog(input_file)
235 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
236 if not files_to_merge:
237 sys.stderr.write('There are no pending changelog entries.\n')
238 return
239 for filename in files_to_merge:
240 with open(filename, 'rb') as input_file:
241 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100242 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100243 if not options.keep_entries:
244 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200245
246def set_defaults(options):
247 """Add default values for missing options."""
248 output_file = getattr(options, 'output', None)
249 if output_file is None:
250 options.output = options.input
251 if getattr(options, 'keep_entries', None) is None:
252 options.keep_entries = (output_file is not None)
253
254def main():
255 """Command line entry point."""
256 parser = argparse.ArgumentParser(description=__doc__)
257 parser.add_argument('--dir', '-d', metavar='DIR',
258 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100259 help='Directory to read entries from'
260 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200261 parser.add_argument('--input', '-i', metavar='FILE',
262 default='ChangeLog.md',
Gilles Peskine6e910092020-01-22 15:58:18 +0100263 help='Existing changelog file to read from and augment'
264 ' (default: ChangeLog.md)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200265 parser.add_argument('--keep-entries',
266 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100267 help='Keep the files containing entries'
268 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200269 parser.add_argument('--no-keep-entries',
270 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100271 help='Remove the files containing entries after they are merged'
272 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200273 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100274 help='Output changelog file'
275 ' (default: overwrite the input)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200276 options = parser.parse_args()
277 set_defaults(options)
278 merge_entries(options)
279
280if __name__ == '__main__':
281 main()