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