Changelog merging script
assemble_changelog.py reads changelog entries from ChangeLog.d/*.md
and merges them into ChangeLog.md.
The changelog entries are merged into the first version in
ChangeLog.md. The script does not yet support creating a new version
in ChangeLog.md.
The changelog entries are merged in alphabetical order of the file
names. Future versions of this script are likely to adopt a different
order that reflects the git history of the entries.
diff --git a/scripts/assemble_changelog.py b/scripts/assemble_changelog.py
new file mode 100755
index 0000000..91db793
--- /dev/null
+++ b/scripts/assemble_changelog.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+
+"""Assemble Mbed Crypto change log entries into the change log file.
+"""
+
+# Copyright (C) 2019, Arm Limited, All Rights Reserved
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# This file is part of Mbed Crypto (https://tls.mbed.org)
+
+import argparse
+import glob
+import os
+import re
+import sys
+
+class InputFormatError(Exception):
+ def __init__(self, filename, line_number, message, *args, **kwargs):
+ self.filename = filename
+ self.line_number = line_number
+ self.message = message.format(*args, **kwargs)
+ def __str__(self):
+ return '{}:{}: {}'.format(self.filename, self.line_number, self.message)
+
+STANDARD_SECTIONS = (
+ b'Interface changes',
+ b'Default behavior changes',
+ b'Requirement changes',
+ b'New deprecations',
+ b'Removals',
+ b'New features',
+ b'Security',
+ b'Bug fixes',
+ b'Performance improvements',
+ b'Other changes',
+)
+
+class ChangeLog:
+ """An Mbed Crypto changelog.
+
+ A changelog is a file in Markdown format. Each level 2 section title
+ starts a version, and versions are sorted in reverse chronological
+ order. Lines with a level 2 section title must start with '##'.
+
+ Within a version, there are multiple sections, each devoted to a kind
+ of change: bug fix, feature request, etc. Section titles should match
+ entries in STANDARD_SECTIONS exactly.
+
+ Within each section, each separate change should be on a line starting
+ with a '*' bullet. There may be blank lines surrounding titles, but
+ there should not be any blank line inside a section.
+ """
+
+ _title_re = re.compile(br'#*')
+ def title_level(self, line):
+ """Determine whether the line is a title.
+
+ Return (level, content) where level is the Markdown section level
+ (1 for '#', 2 for '##', etc.) and content is the section title
+ without leading or trailing whitespace. For a non-title line,
+ the level is 0.
+ """
+ level = re.match(self._title_re, line).end()
+ return level, line[level:].strip()
+
+ def add_sections(self, *sections):
+ """Add the specified section titles to the list of known sections.
+
+ Sections will be printed back out in the order they were added.
+ """
+ for section in sections:
+ if section not in self.section_content:
+ self.section_list.append(section)
+ self.section_content[section] = []
+
+ def __init__(self, input_stream):
+ """Create a changelog object.
+
+ Read lines from input_stream, which is typically a file opened
+ for reading.
+ """
+ level_2_seen = 0
+ current_section = None
+ self.header = []
+ self.section_list = []
+ self.section_content = {}
+ self.add_sections(*STANDARD_SECTIONS)
+ self.trailer = []
+ for line in input_stream:
+ level, content = self.title_level(line)
+ if level == 2:
+ level_2_seen += 1
+ if level_2_seen <= 1:
+ self.header.append(line)
+ else:
+ self.trailer.append(line)
+ elif level == 3 and level_2_seen == 1:
+ current_section = content
+ self.add_sections(current_section)
+ elif level_2_seen == 1 and current_section != None:
+ if line.strip():
+ self.section_content[current_section].append(line)
+ elif level_2_seen <= 1:
+ self.header.append(line)
+ else:
+ self.trailer.append(line)
+
+ def add_file(self, input_stream):
+ """Add changelog entries from a file.
+
+ Read lines from input_stream, which is typically a file opened
+ for reading. These lines must contain a series of level 3
+ Markdown sections with recognized titles. The corresponding
+ content is injected into the respective sections in the changelog.
+ The section titles must be either one of the hard-coded values
+ in assemble_changelog.py or already present in ChangeLog.md.
+ """
+ filename = input_stream.name
+ current_section = None
+ for line_number, line in enumerate(input_stream, 1):
+ if not line.strip():
+ continue
+ level, content = self.title_level(line)
+ if level == 3:
+ current_section = content
+ if current_section not in self.section_content:
+ raise InputFormatError(filename, line_number,
+ 'Section {} is not recognized',
+ str(current_section)[1:])
+ elif level == 0:
+ if current_section is None:
+ raise InputFormatError(filename, line_number,
+ 'Missing section title at the beginning of the file')
+ self.section_content[current_section].append(line)
+ else:
+ raise InputFormatError(filename, line_number,
+ 'Only level 3 headers (###) are permitted')
+
+ def write(self, filename):
+ """Write the changelog to the specified file.
+ """
+ with open(filename, 'wb') as out:
+ for line in self.header:
+ out.write(line)
+ for section in self.section_list:
+ lines = self.section_content[section]
+ while lines and not lines[0].strip():
+ del lines[0]
+ while lines and not lines[-1].strip():
+ del lines[-1]
+ if not lines:
+ continue
+ out.write(b'### ' + section + b'\n\n')
+ for line in lines:
+ out.write(line)
+ out.write(b'\n')
+ for line in self.trailer:
+ out.write(line)
+
+def finish_output(files_to_remove, changelog, output_file):
+ """Write the changelog to the output file.
+
+ Remove the specified input files.
+ """
+ if os.path.exists(output_file) and not os.path.isfile(output_file):
+ # The output is a non-regular file (e.g. pipe). Write to it directly.
+ output_temp = output_file
+ else:
+ # The output is a regular file. Write to a temporary file,
+ # then move it into place atomically.
+ output_temp = output_file + '.tmp'
+ changelog.write(output_temp)
+ for filename in files_to_remove:
+ sys.stderr.write('Removing ' + filename + '\n')
+ #os.remove(filename)
+ if output_temp != output_file:
+ os.rename(output_temp, output_file)
+
+def merge_entries(options):
+ """Merge changelog entries into the changelog file.
+
+ Read the changelog file from options.input.
+ Read entries to merge from the directory options.dir.
+ Write the new changelog to options.output.
+ Remove the merged entries if options.keep_entries is false.
+ """
+ with open(options.input, 'rb') as input_file:
+ changelog = ChangeLog(input_file)
+ files_to_merge = glob.glob(os.path.join(options.dir, '*.md'))
+ if not files_to_merge:
+ sys.stderr.write('There are no pending changelog entries.\n')
+ return
+ for filename in files_to_merge:
+ with open(filename, 'rb') as input_file:
+ changelog.add_file(input_file)
+ files_to_remove = [] if options.keep_entries else files_to_merge
+ finish_output(files_to_remove, changelog, options.output)
+
+def set_defaults(options):
+ """Add default values for missing options."""
+ output_file = getattr(options, 'output', None)
+ if output_file is None:
+ options.output = options.input
+ if getattr(options, 'keep_entries', None) is None:
+ options.keep_entries = (output_file is not None)
+
+def main():
+ """Command line entry point."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--dir', '-d', metavar='DIR',
+ default='ChangeLog.d',
+ help='Directory to read entries from (default: ChangeLog.d)')
+ parser.add_argument('--input', '-i', metavar='FILE',
+ default='ChangeLog.md',
+ help='Existing changelog file to read from and augment (default: ChangeLog.md)')
+ parser.add_argument('--keep-entries',
+ action='store_true', dest='keep_entries', default=None,
+ help='Keep the files containing entries (default: remove them if --output/-o is not specified)')
+ parser.add_argument('--no-keep-entries',
+ action='store_false', dest='keep_entries',
+ help='Remove the files containing entries after they are merged (default: remove them if --output/-o is not specified)')
+ parser.add_argument('--output', '-o', metavar='FILE',
+ help='Output changelog file (default: overwrite the input)')
+ options = parser.parse_args()
+ set_defaults(options)
+ merge_entries(options)
+
+if __name__ == '__main__':
+ main()