blob: 1de57bbbb4b4dae6d72f7078d3ec3f1668db1109 [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):
31 self.filename = filename
32 self.line_number = line_number
33 self.message = message.format(*args, **kwargs)
34 def __str__(self):
35 return '{}:{}: {}'.format(self.filename, self.line_number, self.message)
36
37STANDARD_SECTIONS = (
38 b'Interface changes',
39 b'Default behavior changes',
40 b'Requirement changes',
41 b'New deprecations',
42 b'Removals',
43 b'New features',
44 b'Security',
45 b'Bug fixes',
46 b'Performance improvements',
47 b'Other changes',
48)
49
50class ChangeLog:
51 """An Mbed Crypto changelog.
52
53 A changelog is a file in Markdown format. Each level 2 section title
54 starts a version, and versions are sorted in reverse chronological
55 order. Lines with a level 2 section title must start with '##'.
56
57 Within a version, there are multiple sections, each devoted to a kind
58 of change: bug fix, feature request, etc. Section titles should match
59 entries in STANDARD_SECTIONS exactly.
60
61 Within each section, each separate change should be on a line starting
62 with a '*' bullet. There may be blank lines surrounding titles, but
63 there should not be any blank line inside a section.
64 """
65
66 _title_re = re.compile(br'#*')
67 def title_level(self, line):
68 """Determine whether the line is a title.
69
70 Return (level, content) where level is the Markdown section level
71 (1 for '#', 2 for '##', etc.) and content is the section title
72 without leading or trailing whitespace. For a non-title line,
73 the level is 0.
74 """
75 level = re.match(self._title_re, line).end()
76 return level, line[level:].strip()
77
78 def add_sections(self, *sections):
79 """Add the specified section titles to the list of known sections.
80
81 Sections will be printed back out in the order they were added.
82 """
83 for section in sections:
84 if section not in self.section_content:
85 self.section_list.append(section)
86 self.section_content[section] = []
87
88 def __init__(self, input_stream):
89 """Create a changelog object.
90
Gilles Peskine974232f2020-01-22 12:43:29 +010091 Populate the changelog object from the content of the file
92 input_stream. This is typically a file opened for reading, but
93 can be any generator returning the lines to read.
Gilles Peskine40b3f412019-10-13 21:44:25 +020094 """
95 level_2_seen = 0
96 current_section = None
97 self.header = []
98 self.section_list = []
99 self.section_content = {}
100 self.add_sections(*STANDARD_SECTIONS)
101 self.trailer = []
102 for line in input_stream:
103 level, content = self.title_level(line)
104 if level == 2:
105 level_2_seen += 1
106 if level_2_seen <= 1:
107 self.header.append(line)
108 else:
109 self.trailer.append(line)
110 elif level == 3 and level_2_seen == 1:
111 current_section = content
112 self.add_sections(current_section)
113 elif level_2_seen == 1 and current_section != None:
114 if line.strip():
115 self.section_content[current_section].append(line)
116 elif level_2_seen <= 1:
117 self.header.append(line)
118 else:
119 self.trailer.append(line)
120
121 def add_file(self, input_stream):
122 """Add changelog entries from a file.
123
124 Read lines from input_stream, which is typically a file opened
125 for reading. These lines must contain a series of level 3
126 Markdown sections with recognized titles. The corresponding
127 content is injected into the respective sections in the changelog.
128 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100129 in STANDARD_SECTIONS in assemble_changelog.py or already present
130 in ChangeLog.md. Section titles must match byte-for-byte except that
131 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200132 """
133 filename = input_stream.name
134 current_section = None
135 for line_number, line in enumerate(input_stream, 1):
136 if not line.strip():
137 continue
138 level, content = self.title_level(line)
139 if level == 3:
140 current_section = content
141 if current_section not in self.section_content:
142 raise InputFormatError(filename, line_number,
143 'Section {} is not recognized',
144 str(current_section)[1:])
145 elif level == 0:
146 if current_section is None:
147 raise InputFormatError(filename, line_number,
148 'Missing section title at the beginning of the file')
149 self.section_content[current_section].append(line)
150 else:
151 raise InputFormatError(filename, line_number,
152 'Only level 3 headers (###) are permitted')
153
154 def write(self, filename):
155 """Write the changelog to the specified file.
156 """
157 with open(filename, 'wb') as out:
158 for line in self.header:
159 out.write(line)
160 for section in self.section_list:
161 lines = self.section_content[section]
162 while lines and not lines[0].strip():
163 del lines[0]
164 while lines and not lines[-1].strip():
165 del lines[-1]
166 if not lines:
167 continue
168 out.write(b'### ' + section + b'\n\n')
169 for line in lines:
170 out.write(line)
171 out.write(b'\n')
172 for line in self.trailer:
173 out.write(line)
174
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100175def finish_output(changelog, output_file):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200176 """Write the changelog to the output file.
177
Gilles Peskine40b3f412019-10-13 21:44:25 +0200178 """
179 if os.path.exists(output_file) and not os.path.isfile(output_file):
180 # The output is a non-regular file (e.g. pipe). Write to it directly.
181 output_temp = output_file
182 else:
183 # The output is a regular file. Write to a temporary file,
184 # then move it into place atomically.
185 output_temp = output_file + '.tmp'
186 changelog.write(output_temp)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200187 if output_temp != output_file:
188 os.rename(output_temp, output_file)
189
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100190def remove_merged_entries(files_to_remove):
191 for filename in files_to_remove:
192 os.remove(filename)
193
Gilles Peskine40b3f412019-10-13 21:44:25 +0200194def merge_entries(options):
195 """Merge changelog entries into the changelog file.
196
197 Read the changelog file from options.input.
198 Read entries to merge from the directory options.dir.
199 Write the new changelog to options.output.
200 Remove the merged entries if options.keep_entries is false.
201 """
202 with open(options.input, 'rb') as input_file:
203 changelog = ChangeLog(input_file)
204 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
205 if not files_to_merge:
206 sys.stderr.write('There are no pending changelog entries.\n')
207 return
208 for filename in files_to_merge:
209 with open(filename, 'rb') as input_file:
210 changelog.add_file(input_file)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100211 finish_output(changelog, options.output)
212 if not options.keep_entries:
213 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200214
215def set_defaults(options):
216 """Add default values for missing options."""
217 output_file = getattr(options, 'output', None)
218 if output_file is None:
219 options.output = options.input
220 if getattr(options, 'keep_entries', None) is None:
221 options.keep_entries = (output_file is not None)
222
223def main():
224 """Command line entry point."""
225 parser = argparse.ArgumentParser(description=__doc__)
226 parser.add_argument('--dir', '-d', metavar='DIR',
227 default='ChangeLog.d',
228 help='Directory to read entries from (default: ChangeLog.d)')
229 parser.add_argument('--input', '-i', metavar='FILE',
230 default='ChangeLog.md',
231 help='Existing changelog file to read from and augment (default: ChangeLog.md)')
232 parser.add_argument('--keep-entries',
233 action='store_true', dest='keep_entries', default=None,
234 help='Keep the files containing entries (default: remove them if --output/-o is not specified)')
235 parser.add_argument('--no-keep-entries',
236 action='store_false', dest='keep_entries',
237 help='Remove the files containing entries after they are merged (default: remove them if --output/-o is not specified)')
238 parser.add_argument('--output', '-o', metavar='FILE',
239 help='Output changelog file (default: overwrite the input)')
240 options = parser.parse_args()
241 set_defaults(options)
242 merge_entries(options)
243
244if __name__ == '__main__':
245 main()