blob: 91db79350fda5e60b9b8a0d628f74377779c0ec1 [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
91 Read lines from input_stream, which is typically a file opened
92 for reading.
93 """
94 level_2_seen = 0
95 current_section = None
96 self.header = []
97 self.section_list = []
98 self.section_content = {}
99 self.add_sections(*STANDARD_SECTIONS)
100 self.trailer = []
101 for line in input_stream:
102 level, content = self.title_level(line)
103 if level == 2:
104 level_2_seen += 1
105 if level_2_seen <= 1:
106 self.header.append(line)
107 else:
108 self.trailer.append(line)
109 elif level == 3 and level_2_seen == 1:
110 current_section = content
111 self.add_sections(current_section)
112 elif level_2_seen == 1 and current_section != None:
113 if line.strip():
114 self.section_content[current_section].append(line)
115 elif level_2_seen <= 1:
116 self.header.append(line)
117 else:
118 self.trailer.append(line)
119
120 def add_file(self, input_stream):
121 """Add changelog entries from a file.
122
123 Read lines from input_stream, which is typically a file opened
124 for reading. These lines must contain a series of level 3
125 Markdown sections with recognized titles. The corresponding
126 content is injected into the respective sections in the changelog.
127 The section titles must be either one of the hard-coded values
128 in assemble_changelog.py or already present in ChangeLog.md.
129 """
130 filename = input_stream.name
131 current_section = None
132 for line_number, line in enumerate(input_stream, 1):
133 if not line.strip():
134 continue
135 level, content = self.title_level(line)
136 if level == 3:
137 current_section = content
138 if current_section not in self.section_content:
139 raise InputFormatError(filename, line_number,
140 'Section {} is not recognized',
141 str(current_section)[1:])
142 elif level == 0:
143 if current_section is None:
144 raise InputFormatError(filename, line_number,
145 'Missing section title at the beginning of the file')
146 self.section_content[current_section].append(line)
147 else:
148 raise InputFormatError(filename, line_number,
149 'Only level 3 headers (###) are permitted')
150
151 def write(self, filename):
152 """Write the changelog to the specified file.
153 """
154 with open(filename, 'wb') as out:
155 for line in self.header:
156 out.write(line)
157 for section in self.section_list:
158 lines = self.section_content[section]
159 while lines and not lines[0].strip():
160 del lines[0]
161 while lines and not lines[-1].strip():
162 del lines[-1]
163 if not lines:
164 continue
165 out.write(b'### ' + section + b'\n\n')
166 for line in lines:
167 out.write(line)
168 out.write(b'\n')
169 for line in self.trailer:
170 out.write(line)
171
172def finish_output(files_to_remove, changelog, output_file):
173 """Write the changelog to the output file.
174
175 Remove the specified input files.
176 """
177 if os.path.exists(output_file) and not os.path.isfile(output_file):
178 # The output is a non-regular file (e.g. pipe). Write to it directly.
179 output_temp = output_file
180 else:
181 # The output is a regular file. Write to a temporary file,
182 # then move it into place atomically.
183 output_temp = output_file + '.tmp'
184 changelog.write(output_temp)
185 for filename in files_to_remove:
186 sys.stderr.write('Removing ' + filename + '\n')
187 #os.remove(filename)
188 if output_temp != output_file:
189 os.rename(output_temp, output_file)
190
191def merge_entries(options):
192 """Merge changelog entries into the changelog file.
193
194 Read the changelog file from options.input.
195 Read entries to merge from the directory options.dir.
196 Write the new changelog to options.output.
197 Remove the merged entries if options.keep_entries is false.
198 """
199 with open(options.input, 'rb') as input_file:
200 changelog = ChangeLog(input_file)
201 files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
202 if not files_to_merge:
203 sys.stderr.write('There are no pending changelog entries.\n')
204 return
205 for filename in files_to_merge:
206 with open(filename, 'rb') as input_file:
207 changelog.add_file(input_file)
208 files_to_remove = [] if options.keep_entries else files_to_merge
209 finish_output(files_to_remove, changelog, options.output)
210
211def set_defaults(options):
212 """Add default values for missing options."""
213 output_file = getattr(options, 'output', None)
214 if output_file is None:
215 options.output = options.input
216 if getattr(options, 'keep_entries', None) is None:
217 options.keep_entries = (output_file is not None)
218
219def main():
220 """Command line entry point."""
221 parser = argparse.ArgumentParser(description=__doc__)
222 parser.add_argument('--dir', '-d', metavar='DIR',
223 default='ChangeLog.d',
224 help='Directory to read entries from (default: ChangeLog.d)')
225 parser.add_argument('--input', '-i', metavar='FILE',
226 default='ChangeLog.md',
227 help='Existing changelog file to read from and augment (default: ChangeLog.md)')
228 parser.add_argument('--keep-entries',
229 action='store_true', dest='keep_entries', default=None,
230 help='Keep the files containing entries (default: remove them if --output/-o is not specified)')
231 parser.add_argument('--no-keep-entries',
232 action='store_false', dest='keep_entries',
233 help='Remove the files containing entries after they are merged (default: remove them if --output/-o is not specified)')
234 parser.add_argument('--output', '-o', metavar='FILE',
235 help='Output changelog file (default: overwrite the input)')
236 options = parser.parse_args()
237 set_defaults(options)
238 merge_entries(options)
239
240if __name__ == '__main__':
241 main()