blob: b9a647f2af454a09ec798cfc81ba1c91a8fac3bf [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
24import glob
25import os
26import re
27import sys
28
29class InputFormatError(Exception):
30 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010031 message = '{}:{}: {}'.format(filename, line_number,
32 message.format(*args, **kwargs))
33 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020034
Gilles Peskine2b242492020-01-22 15:41:50 +010035class LostContent(Exception):
36 def __init__(self, filename, line):
37 message = ('Lost content from {}: "{}"'.format(filename, line))
38 super().__init__(message)
39
Gilles Peskine40b3f412019-10-13 21:44:25 +020040STANDARD_SECTIONS = (
41 b'Interface changes',
42 b'Default behavior changes',
43 b'Requirement changes',
44 b'New deprecations',
45 b'Removals',
46 b'New features',
47 b'Security',
48 b'Bug fixes',
49 b'Performance improvements',
50 b'Other changes',
51)
52
53class ChangeLog:
54 """An Mbed Crypto changelog.
55
56 A changelog is a file in Markdown format. Each level 2 section title
57 starts a version, and versions are sorted in reverse chronological
58 order. Lines with a level 2 section title must start with '##'.
59
60 Within a version, there are multiple sections, each devoted to a kind
61 of change: bug fix, feature request, etc. Section titles should match
62 entries in STANDARD_SECTIONS exactly.
63
64 Within each section, each separate change should be on a line starting
65 with a '*' bullet. There may be blank lines surrounding titles, but
66 there should not be any blank line inside a section.
67 """
68
69 _title_re = re.compile(br'#*')
70 def title_level(self, line):
71 """Determine whether the line is a title.
72
73 Return (level, content) where level is the Markdown section level
74 (1 for '#', 2 for '##', etc.) and content is the section title
75 without leading or trailing whitespace. For a non-title line,
76 the level is 0.
77 """
78 level = re.match(self._title_re, line).end()
79 return level, line[level:].strip()
80
81 def add_sections(self, *sections):
82 """Add the specified section titles to the list of known sections.
83
84 Sections will be printed back out in the order they were added.
85 """
86 for section in sections:
87 if section not in self.section_content:
88 self.section_list.append(section)
89 self.section_content[section] = []
90
91 def __init__(self, input_stream):
92 """Create a changelog object.
93
Gilles Peskine974232f2020-01-22 12:43:29 +010094 Populate the changelog object from the content of the file
95 input_stream. This is typically a file opened for reading, but
96 can be any generator returning the lines to read.
Gilles Peskine40b3f412019-10-13 21:44:25 +020097 """
Gilles Peskine40b3f412019-10-13 21:44:25 +020098 self.header = []
99 self.section_list = []
100 self.section_content = {}
101 self.add_sections(*STANDARD_SECTIONS)
102 self.trailer = []
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100103 self.read_main_file(input_stream)
104
105 def read_main_file(self, input_stream):
106 """Populate the changelog object from the content of the file.
107
108 This method is only intended to be called as part of the constructor
109 of the class and may not act sensibly on an object that is already
110 partially populated.
111 """
112 level_2_seen = 0
113 current_section = None
Gilles Peskine40b3f412019-10-13 21:44:25 +0200114 for line in input_stream:
115 level, content = self.title_level(line)
116 if level == 2:
117 level_2_seen += 1
118 if level_2_seen <= 1:
119 self.header.append(line)
120 else:
121 self.trailer.append(line)
122 elif level == 3 and level_2_seen == 1:
123 current_section = content
124 self.add_sections(current_section)
125 elif level_2_seen == 1 and current_section != None:
126 if line.strip():
127 self.section_content[current_section].append(line)
128 elif level_2_seen <= 1:
129 self.header.append(line)
130 else:
131 self.trailer.append(line)
132
133 def add_file(self, input_stream):
134 """Add changelog entries from a file.
135
136 Read lines from input_stream, which is typically a file opened
137 for reading. These lines must contain a series of level 3
138 Markdown sections with recognized titles. The corresponding
139 content is injected into the respective sections in the changelog.
140 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100141 in STANDARD_SECTIONS in assemble_changelog.py or already present
142 in ChangeLog.md. Section titles must match byte-for-byte except that
143 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200144 """
145 filename = input_stream.name
146 current_section = None
147 for line_number, line in enumerate(input_stream, 1):
148 if not line.strip():
149 continue
150 level, content = self.title_level(line)
151 if level == 3:
152 current_section = content
153 if current_section not in self.section_content:
154 raise InputFormatError(filename, line_number,
155 'Section {} is not recognized',
156 str(current_section)[1:])
157 elif level == 0:
158 if current_section is None:
159 raise InputFormatError(filename, line_number,
160 'Missing section title at the beginning of the file')
161 self.section_content[current_section].append(line)
162 else:
163 raise InputFormatError(filename, line_number,
164 'Only level 3 headers (###) are permitted')
165
166 def write(self, filename):
167 """Write the changelog to the specified file.
168 """
169 with open(filename, 'wb') as out:
170 for line in self.header:
171 out.write(line)
172 for section in self.section_list:
173 lines = self.section_content[section]
174 while lines and not lines[0].strip():
175 del lines[0]
176 while lines and not lines[-1].strip():
177 del lines[-1]
178 if not lines:
179 continue
180 out.write(b'### ' + section + b'\n\n')
181 for line in lines:
182 out.write(line)
183 out.write(b'\n')
184 for line in self.trailer:
185 out.write(line)
186
Gilles Peskine2b242492020-01-22 15:41:50 +0100187def check_output(generated_output_file, main_input_file, merged_files):
188 """Make sanity checks on the generated output.
189
190 The intent of these sanity checks is to have reasonable confidence
191 that no content has been lost.
192
193 The sanity check is that every line that is present in an input file
194 is also present in an output file. This is not perfect but good enough
195 for now.
196 """
197 generated_output = set(open(generated_output_file, 'rb'))
198 for line in open(main_input_file, 'rb'):
199 if line not in generated_output:
200 raise LostContent('original file', line)
201 for merged_file in merged_files:
202 for line in open(merged_file, 'rb'):
203 if line not in generated_output:
204 raise LostContent(merged_file, line)
205
206def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200207 """Write the changelog to the output file.
208
Gilles Peskine2b242492020-01-22 15:41:50 +0100209 The input file and the list of merged files are used only for sanity
210 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200211 """
212 if os.path.exists(output_file) and not os.path.isfile(output_file):
213 # The output is a non-regular file (e.g. pipe). Write to it directly.
214 output_temp = output_file
215 else:
216 # The output is a regular file. Write to a temporary file,
217 # then move it into place atomically.
218 output_temp = output_file + '.tmp'
219 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100220 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200221 if output_temp != output_file:
222 os.rename(output_temp, output_file)
223
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100224def remove_merged_entries(files_to_remove):
225 for filename in files_to_remove:
226 os.remove(filename)
227
Gilles Peskine40b3f412019-10-13 21:44:25 +0200228def merge_entries(options):
229 """Merge changelog entries into the changelog file.
230
231 Read the changelog file from options.input.
232 Read entries to merge from the directory options.dir.
233 Write the new changelog to options.output.
234 Remove the merged entries if options.keep_entries is false.
235 """
236 with open(options.input, 'rb') as input_file:
237 changelog = ChangeLog(input_file)
238 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
239 if not files_to_merge:
240 sys.stderr.write('There are no pending changelog entries.\n')
241 return
242 for filename in files_to_merge:
243 with open(filename, 'rb') as input_file:
244 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100245 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100246 if not options.keep_entries:
247 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200248
249def set_defaults(options):
250 """Add default values for missing options."""
251 output_file = getattr(options, 'output', None)
252 if output_file is None:
253 options.output = options.input
254 if getattr(options, 'keep_entries', None) is None:
255 options.keep_entries = (output_file is not None)
256
257def main():
258 """Command line entry point."""
259 parser = argparse.ArgumentParser(description=__doc__)
260 parser.add_argument('--dir', '-d', metavar='DIR',
261 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100262 help='Directory to read entries from'
263 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200264 parser.add_argument('--input', '-i', metavar='FILE',
265 default='ChangeLog.md',
Gilles Peskine6e910092020-01-22 15:58:18 +0100266 help='Existing changelog file to read from and augment'
267 ' (default: ChangeLog.md)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200268 parser.add_argument('--keep-entries',
269 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100270 help='Keep the files containing entries'
271 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200272 parser.add_argument('--no-keep-entries',
273 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100274 help='Remove the files containing entries after they are merged'
275 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200276 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100277 help='Output changelog file'
278 ' (default: overwrite the input)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200279 options = parser.parse_args()
280 set_defaults(options)
281 merge_entries(options)
282
283if __name__ == '__main__':
284 main()