2019-10-13 19:44:25 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
"""Assemble Mbed Crypto change log entries into the change log file.
|
2020-01-28 18:58:17 +00:00
|
|
|
|
|
|
|
Add changelog entries to the first level-2 section.
|
|
|
|
Create a new level-2 section for unreleased changes if needed.
|
|
|
|
Remove the input files unless --keep-entries is specified.
|
2019-10-13 19:44:25 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
# 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
|
2020-01-28 17:57:47 +00:00
|
|
|
from collections import OrderedDict
|
2019-10-13 19:44:25 +00:00
|
|
|
import glob
|
|
|
|
import os
|
|
|
|
import re
|
|
|
|
import sys
|
|
|
|
|
|
|
|
class InputFormatError(Exception):
|
|
|
|
def __init__(self, filename, line_number, message, *args, **kwargs):
|
2020-01-22 14:55:36 +00:00
|
|
|
message = '{}:{}: {}'.format(filename, line_number,
|
|
|
|
message.format(*args, **kwargs))
|
|
|
|
super().__init__(message)
|
2019-10-13 19:44:25 +00:00
|
|
|
|
2020-01-22 14:41:50 +00:00
|
|
|
class LostContent(Exception):
|
|
|
|
def __init__(self, filename, line):
|
|
|
|
message = ('Lost content from {}: "{}"'.format(filename, line))
|
|
|
|
super().__init__(message)
|
|
|
|
|
2019-10-13 19:44:25 +00:00
|
|
|
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()
|
|
|
|
|
2020-01-28 18:58:17 +00:00
|
|
|
# Only accept dotted version numbers (e.g. "3.1", not "3").
|
Fix version number recognition heuristics
The regexp was wrong, for example it matched "2.20x" but failed to
match "3.1".
Some test cases:
>>> def f(title):
... version_number = re.search(_version_number_re, title)
... if version_number:
... return not re.search(_incomplete_version_number_re,
... version_number.group(0))
... else:
... return False
...
>>> [(s, f(s.encode('ascii'))) for s in ['foo', 'foo 3', 'foo 3.', 'foo 3.1', 'foo 3.14', 'foo 3.2.1', 'foo 3.2.1alpha', 'foo 3.1.a', 'foo 3.a', 'foo 3.x.1']]
[('foo', False), ('foo 3', False), ('foo 3.', False), ('foo 3.1', True), ('foo 3.14', True), ('foo 3.2.1', True), ('foo 3.2.1alpha', True), ('foo 3.1.a', False), ('foo 3.a', False), ('foo 3.x.1', False)]
2020-01-30 10:38:01 +00:00
|
|
|
# Refuse ".x" in a version number where x is a letter: this indicates
|
|
|
|
# a version that is not yet released. Something like "3.1a" is accepted.
|
|
|
|
_version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
|
|
|
|
_incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
|
2020-01-28 18:58:17 +00:00
|
|
|
|
|
|
|
def section_is_released_version(self, title):
|
|
|
|
"""Whether this section is for a released version.
|
|
|
|
|
|
|
|
True if the given level-2 section title indicates that this section
|
|
|
|
contains released changes, otherwise False.
|
|
|
|
"""
|
|
|
|
# Assume that a released version has a numerical version number
|
|
|
|
# that follows a particular pattern. These criteria may be revised
|
|
|
|
# as needed in future versions of this script.
|
|
|
|
version_number = re.search(self._version_number_re, title)
|
Fix version number recognition heuristics
The regexp was wrong, for example it matched "2.20x" but failed to
match "3.1".
Some test cases:
>>> def f(title):
... version_number = re.search(_version_number_re, title)
... if version_number:
... return not re.search(_incomplete_version_number_re,
... version_number.group(0))
... else:
... return False
...
>>> [(s, f(s.encode('ascii'))) for s in ['foo', 'foo 3', 'foo 3.', 'foo 3.1', 'foo 3.14', 'foo 3.2.1', 'foo 3.2.1alpha', 'foo 3.1.a', 'foo 3.a', 'foo 3.x.1']]
[('foo', False), ('foo 3', False), ('foo 3.', False), ('foo 3.1', True), ('foo 3.14', True), ('foo 3.2.1', True), ('foo 3.2.1alpha', True), ('foo 3.1.a', False), ('foo 3.a', False), ('foo 3.x.1', False)]
2020-01-30 10:38:01 +00:00
|
|
|
if version_number:
|
|
|
|
return not re.search(self._incomplete_version_number_re,
|
|
|
|
version_number.group(0))
|
|
|
|
else:
|
|
|
|
return False
|
2020-01-28 18:58:17 +00:00
|
|
|
|
|
|
|
def unreleased_version_title(self):
|
|
|
|
"""The title to use if creating a new section for an unreleased version."""
|
|
|
|
# pylint: disable=no-self-use; this method may be overridden
|
|
|
|
return b'Unreleased changes'
|
|
|
|
|
2019-10-13 19:44:25 +00:00
|
|
|
def __init__(self, input_stream):
|
|
|
|
"""Create a changelog object.
|
|
|
|
|
2020-01-22 11:43:29 +00:00
|
|
|
Populate the changelog object from the content of the file
|
|
|
|
input_stream. This is typically a file opened for reading, but
|
|
|
|
can be any generator returning the lines to read.
|
2019-10-13 19:44:25 +00:00
|
|
|
"""
|
2020-01-28 18:14:15 +00:00
|
|
|
# Content before the level-2 section where the new entries are to be
|
|
|
|
# added.
|
2019-10-13 19:44:25 +00:00
|
|
|
self.header = []
|
2020-01-28 18:14:15 +00:00
|
|
|
# Content of the level-3 sections of where the new entries are to
|
|
|
|
# be added.
|
2020-01-28 17:57:47 +00:00
|
|
|
self.section_content = OrderedDict()
|
|
|
|
for section in STANDARD_SECTIONS:
|
|
|
|
self.section_content[section] = []
|
2020-01-28 18:14:15 +00:00
|
|
|
# Content of level-2 sections for already-released versions.
|
2019-10-13 19:44:25 +00:00
|
|
|
self.trailer = []
|
2020-01-22 14:40:39 +00:00
|
|
|
self.read_main_file(input_stream)
|
|
|
|
|
|
|
|
def read_main_file(self, input_stream):
|
|
|
|
"""Populate the changelog object from the content of the file.
|
|
|
|
|
|
|
|
This method is only intended to be called as part of the constructor
|
|
|
|
of the class and may not act sensibly on an object that is already
|
|
|
|
partially populated.
|
|
|
|
"""
|
2020-01-28 18:58:17 +00:00
|
|
|
# Parse the first level-2 section, containing changelog entries
|
|
|
|
# for unreleased changes.
|
|
|
|
# If we'll be expanding this section, everything before the first
|
2020-01-28 18:14:15 +00:00
|
|
|
# level-3 section title ("###...") following the first level-2
|
|
|
|
# section title ("##...") is passed through as the header
|
|
|
|
# and everything after the second level-2 section title is passed
|
|
|
|
# through as the trailer. Inside the first level-2 section,
|
|
|
|
# split out the level-3 sections.
|
2020-01-28 18:58:17 +00:00
|
|
|
# If we'll be creating a new version, the header is everything
|
|
|
|
# before the point where we want to add the level-2 section
|
|
|
|
# for this version, and the trailer is what follows.
|
2020-01-22 14:40:39 +00:00
|
|
|
level_2_seen = 0
|
|
|
|
current_section = None
|
2019-10-13 19:44:25 +00:00
|
|
|
for line in input_stream:
|
|
|
|
level, content = self.title_level(line)
|
|
|
|
if level == 2:
|
|
|
|
level_2_seen += 1
|
2020-01-28 18:58:17 +00:00
|
|
|
if level_2_seen == 1:
|
|
|
|
if self.section_is_released_version(content):
|
|
|
|
self.header.append(b'## ' +
|
|
|
|
self.unreleased_version_title() +
|
|
|
|
b'\n\n')
|
|
|
|
level_2_seen = 2
|
2019-10-13 19:44:25 +00:00
|
|
|
elif level == 3 and level_2_seen == 1:
|
|
|
|
current_section = content
|
2020-01-28 17:57:47 +00:00
|
|
|
self.section_content.setdefault(content, [])
|
2020-01-28 18:14:15 +00:00
|
|
|
if level_2_seen == 1 and current_section is not None:
|
|
|
|
if level != 3 and line.strip():
|
2019-10-13 19:44:25 +00:00
|
|
|
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
|
2020-01-22 11:43:29 +00:00
|
|
|
in STANDARD_SECTIONS in assemble_changelog.py or already present
|
|
|
|
in ChangeLog.md. Section titles must match byte-for-byte except that
|
|
|
|
leading or trailing whitespace is ignored.
|
2019-10-13 19:44:25 +00:00
|
|
|
"""
|
|
|
|
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)
|
2020-01-28 17:57:47 +00:00
|
|
|
for section, lines in self.section_content.items():
|
2019-10-13 19:44:25 +00:00
|
|
|
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)
|
|
|
|
|
2020-01-22 14:41:50 +00:00
|
|
|
def check_output(generated_output_file, main_input_file, merged_files):
|
|
|
|
"""Make sanity checks on the generated output.
|
|
|
|
|
|
|
|
The intent of these sanity checks is to have reasonable confidence
|
|
|
|
that no content has been lost.
|
|
|
|
|
|
|
|
The sanity check is that every line that is present in an input file
|
|
|
|
is also present in an output file. This is not perfect but good enough
|
|
|
|
for now.
|
|
|
|
"""
|
|
|
|
generated_output = set(open(generated_output_file, 'rb'))
|
|
|
|
for line in open(main_input_file, 'rb'):
|
|
|
|
if line not in generated_output:
|
|
|
|
raise LostContent('original file', line)
|
|
|
|
for merged_file in merged_files:
|
|
|
|
for line in open(merged_file, 'rb'):
|
|
|
|
if line not in generated_output:
|
|
|
|
raise LostContent(merged_file, line)
|
|
|
|
|
|
|
|
def finish_output(changelog, output_file, input_file, merged_files):
|
2019-10-13 19:44:25 +00:00
|
|
|
"""Write the changelog to the output file.
|
|
|
|
|
2020-01-22 14:41:50 +00:00
|
|
|
The input file and the list of merged files are used only for sanity
|
|
|
|
checks on the output.
|
2019-10-13 19:44:25 +00:00
|
|
|
"""
|
|
|
|
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)
|
2020-01-22 14:41:50 +00:00
|
|
|
check_output(output_temp, input_file, merged_files)
|
2019-10-13 19:44:25 +00:00
|
|
|
if output_temp != output_file:
|
|
|
|
os.rename(output_temp, output_file)
|
|
|
|
|
2020-01-22 13:55:37 +00:00
|
|
|
def remove_merged_entries(files_to_remove):
|
|
|
|
for filename in files_to_remove:
|
|
|
|
os.remove(filename)
|
|
|
|
|
2019-10-13 19:44:25 +00:00
|
|
|
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)
|
2020-01-22 14:41:50 +00:00
|
|
|
finish_output(changelog, options.output, options.input, files_to_merge)
|
2020-01-22 13:55:37 +00:00
|
|
|
if not options.keep_entries:
|
|
|
|
remove_merged_entries(files_to_merge)
|
2019-10-13 19:44:25 +00:00
|
|
|
|
|
|
|
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',
|
2020-01-22 14:58:18 +00:00
|
|
|
help='Directory to read entries from'
|
|
|
|
' (default: ChangeLog.d)')
|
2019-10-13 19:44:25 +00:00
|
|
|
parser.add_argument('--input', '-i', metavar='FILE',
|
|
|
|
default='ChangeLog.md',
|
2020-01-22 14:58:18 +00:00
|
|
|
help='Existing changelog file to read from and augment'
|
|
|
|
' (default: ChangeLog.md)')
|
2019-10-13 19:44:25 +00:00
|
|
|
parser.add_argument('--keep-entries',
|
|
|
|
action='store_true', dest='keep_entries', default=None,
|
2020-01-22 14:58:18 +00:00
|
|
|
help='Keep the files containing entries'
|
|
|
|
' (default: remove them if --output/-o is not specified)')
|
2019-10-13 19:44:25 +00:00
|
|
|
parser.add_argument('--no-keep-entries',
|
|
|
|
action='store_false', dest='keep_entries',
|
2020-01-22 14:58:18 +00:00
|
|
|
help='Remove the files containing entries after they are merged'
|
|
|
|
' (default: remove them if --output/-o is not specified)')
|
2019-10-13 19:44:25 +00:00
|
|
|
parser.add_argument('--output', '-o', metavar='FILE',
|
2020-01-22 14:58:18 +00:00
|
|
|
help='Output changelog file'
|
|
|
|
' (default: overwrite the input)')
|
2019-10-13 19:44:25 +00:00
|
|
|
options = parser.parse_args()
|
|
|
|
set_defaults(options)
|
|
|
|
merge_entries(options)
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|