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.
This commit is contained in:
parent
b1320f3319
commit
40b3f411ec
2 changed files with 262 additions and 0 deletions
21
ChangeLog.d/README
Normal file
21
ChangeLog.d/README
Normal file
|
@ -0,0 +1,21 @@
|
|||
This directory contains changelog entries that have not yet been merged
|
||||
to the changelog file (../ChangeLog.md).
|
||||
|
||||
A changelog entry file must have the extension *.md and must have the
|
||||
following format:
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
### Section title
|
||||
|
||||
* Change descritpion.
|
||||
* Another change description.
|
||||
|
||||
### Another section title
|
||||
|
||||
* Yet another change description.
|
||||
* Yet again another change description.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
See STANDARD_SECTIONS in ../scripts/assemble_changelog.py for
|
||||
recognized section titles.
|
241
scripts/assemble_changelog.py
Executable file
241
scripts/assemble_changelog.py
Executable file
|
@ -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()
|
Loading…
Reference in a new issue