blob: 1e59bb0bcaf3add7f876dfd332839b639dbb1548 [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 """
Gilles Peskine40b3f412019-10-13 21:44:25 +020095 self.header = []
96 self.section_list = []
97 self.section_content = {}
98 self.add_sections(*STANDARD_SECTIONS)
99 self.trailer = []
Gilles Peskine8c4a84c2020-01-22 15:40:39 +0100100 self.read_main_file(input_stream)
101
102 def read_main_file(self, input_stream):
103 """Populate the changelog object from the content of the file.
104
105 This method is only intended to be called as part of the constructor
106 of the class and may not act sensibly on an object that is already
107 partially populated.
108 """
109 level_2_seen = 0
110 current_section = None
Gilles Peskine40b3f412019-10-13 21:44:25 +0200111 for line in input_stream:
112 level, content = self.title_level(line)
113 if level == 2:
114 level_2_seen += 1
115 if level_2_seen <= 1:
116 self.header.append(line)
117 else:
118 self.trailer.append(line)
119 elif level == 3 and level_2_seen == 1:
120 current_section = content
121 self.add_sections(current_section)
122 elif level_2_seen == 1 and current_section != None:
123 if line.strip():
124 self.section_content[current_section].append(line)
125 elif level_2_seen <= 1:
126 self.header.append(line)
127 else:
128 self.trailer.append(line)
129
130 def add_file(self, input_stream):
131 """Add changelog entries from a file.
132
133 Read lines from input_stream, which is typically a file opened
134 for reading. These lines must contain a series of level 3
135 Markdown sections with recognized titles. The corresponding
136 content is injected into the respective sections in the changelog.
137 The section titles must be either one of the hard-coded values
Gilles Peskine974232f2020-01-22 12:43:29 +0100138 in STANDARD_SECTIONS in assemble_changelog.py or already present
139 in ChangeLog.md. Section titles must match byte-for-byte except that
140 leading or trailing whitespace is ignored.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200141 """
142 filename = input_stream.name
143 current_section = None
144 for line_number, line in enumerate(input_stream, 1):
145 if not line.strip():
146 continue
147 level, content = self.title_level(line)
148 if level == 3:
149 current_section = content
150 if current_section not in self.section_content:
151 raise InputFormatError(filename, line_number,
152 'Section {} is not recognized',
153 str(current_section)[1:])
154 elif level == 0:
155 if current_section is None:
156 raise InputFormatError(filename, line_number,
157 'Missing section title at the beginning of the file')
158 self.section_content[current_section].append(line)
159 else:
160 raise InputFormatError(filename, line_number,
161 'Only level 3 headers (###) are permitted')
162
163 def write(self, filename):
164 """Write the changelog to the specified file.
165 """
166 with open(filename, 'wb') as out:
167 for line in self.header:
168 out.write(line)
169 for section in self.section_list:
170 lines = self.section_content[section]
171 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 Peskine5e39c9e2020-01-22 14:55:37 +0100184def finish_output(changelog, output_file):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200185 """Write the changelog to the output file.
186
Gilles Peskine40b3f412019-10-13 21:44:25 +0200187 """
188 if os.path.exists(output_file) and not os.path.isfile(output_file):
189 # The output is a non-regular file (e.g. pipe). Write to it directly.
190 output_temp = output_file
191 else:
192 # The output is a regular file. Write to a temporary file,
193 # then move it into place atomically.
194 output_temp = output_file + '.tmp'
195 changelog.write(output_temp)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200196 if output_temp != output_file:
197 os.rename(output_temp, output_file)
198
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100199def remove_merged_entries(files_to_remove):
200 for filename in files_to_remove:
201 os.remove(filename)
202
Gilles Peskine40b3f412019-10-13 21:44:25 +0200203def merge_entries(options):
204 """Merge changelog entries into the changelog file.
205
206 Read the changelog file from options.input.
207 Read entries to merge from the directory options.dir.
208 Write the new changelog to options.output.
209 Remove the merged entries if options.keep_entries is false.
210 """
211 with open(options.input, 'rb') as input_file:
212 changelog = ChangeLog(input_file)
213 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
214 if not files_to_merge:
215 sys.stderr.write('There are no pending changelog entries.\n')
216 return
217 for filename in files_to_merge:
218 with open(filename, 'rb') as input_file:
219 changelog.add_file(input_file)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100220 finish_output(changelog, options.output)
221 if not options.keep_entries:
222 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200223
224def set_defaults(options):
225 """Add default values for missing options."""
226 output_file = getattr(options, 'output', None)
227 if output_file is None:
228 options.output = options.input
229 if getattr(options, 'keep_entries', None) is None:
230 options.keep_entries = (output_file is not None)
231
232def main():
233 """Command line entry point."""
234 parser = argparse.ArgumentParser(description=__doc__)
235 parser.add_argument('--dir', '-d', metavar='DIR',
236 default='ChangeLog.d',
237 help='Directory to read entries from (default: ChangeLog.d)')
238 parser.add_argument('--input', '-i', metavar='FILE',
239 default='ChangeLog.md',
240 help='Existing changelog file to read from and augment (default: ChangeLog.md)')
241 parser.add_argument('--keep-entries',
242 action='store_true', dest='keep_entries', default=None,
243 help='Keep the files containing entries (default: remove them if --output/-o is not specified)')
244 parser.add_argument('--no-keep-entries',
245 action='store_false', dest='keep_entries',
246 help='Remove the files containing entries after they are merged (default: remove them if --output/-o is not specified)')
247 parser.add_argument('--output', '-o', metavar='FILE',
248 help='Output changelog file (default: overwrite the input)')
249 options = parser.parse_args()
250 set_defaults(options)
251 merge_entries(options)
252
253if __name__ == '__main__':
254 main()