1#!/usr/bin/env python3 2 3"""Assemble Mbed TLS change log entries into the change log file. 4 5Add changelog entries to the first level-2 section. 6Create a new level-2 section for unreleased changes if needed. 7Remove the input files unless --keep-entries is specified. 8 9In each level-3 section, entries are sorted in chronological order 10(oldest first). From oldest to newest: 11* Merged entry files are sorted according to their merge date (date of 12 the merge commit that brought the commit that created the file into 13 the target branch). 14* Committed but unmerged entry files are sorted according to the date 15 of the commit that adds them. 16* Uncommitted entry files are sorted according to their modification time. 17 18You must run this program from within a git working directory. 19""" 20 21# Copyright The Mbed TLS Contributors 22# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later 23# 24# This file is provided under the Apache License 2.0, or the 25# GNU General Public License v2.0 or later. 26# 27# ********** 28# Apache License 2.0: 29# 30# Licensed under the Apache License, Version 2.0 (the "License"); you may 31# not use this file except in compliance with the License. 32# You may obtain a copy of the License at 33# 34# http://www.apache.org/licenses/LICENSE-2.0 35# 36# Unless required by applicable law or agreed to in writing, software 37# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 38# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 39# See the License for the specific language governing permissions and 40# limitations under the License. 41# 42# ********** 43# 44# ********** 45# GNU General Public License v2.0 or later: 46# 47# This program is free software; you can redistribute it and/or modify 48# it under the terms of the GNU General Public License as published by 49# the Free Software Foundation; either version 2 of the License, or 50# (at your option) any later version. 51# 52# This program is distributed in the hope that it will be useful, 53# but WITHOUT ANY WARRANTY; without even the implied warranty of 54# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 55# GNU General Public License for more details. 56# 57# You should have received a copy of the GNU General Public License along 58# with this program; if not, write to the Free Software Foundation, Inc., 59# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 60# 61# ********** 62 63import argparse 64from collections import OrderedDict, namedtuple 65import datetime 66import functools 67import glob 68import os 69import re 70import subprocess 71import sys 72 73class InputFormatError(Exception): 74 def __init__(self, filename, line_number, message, *args, **kwargs): 75 message = '{}:{}: {}'.format(filename, line_number, 76 message.format(*args, **kwargs)) 77 super().__init__(message) 78 79class CategoryParseError(Exception): 80 def __init__(self, line_offset, error_message): 81 self.line_offset = line_offset 82 self.error_message = error_message 83 super().__init__('{}: {}'.format(line_offset, error_message)) 84 85class LostContent(Exception): 86 def __init__(self, filename, line): 87 message = ('Lost content from {}: "{}"'.format(filename, line)) 88 super().__init__(message) 89 90# The category names we use in the changelog. 91# If you edit this, update ChangeLog.d/README.md. 92STANDARD_CATEGORIES = ( 93 b'API changes', 94 b'Default behavior changes', 95 b'Requirement changes', 96 b'New deprecations', 97 b'Removals', 98 b'Features', 99 b'Security', 100 b'Bugfix', 101 b'Changes', 102) 103 104CategoryContent = namedtuple('CategoryContent', [ 105 'name', 'title_line', # Title text and line number of the title 106 'body', 'body_line', # Body text and starting line number of the body 107]) 108 109class ChangelogFormat: 110 """Virtual class documenting how to write a changelog format class.""" 111 112 @classmethod 113 def extract_top_version(cls, changelog_file_content): 114 """Split out the top version section. 115 116 If the top version is already released, create a new top 117 version section for an unreleased version. 118 119 Return ``(header, top_version_title, top_version_body, trailer)`` 120 where the "top version" is the existing top version section if it's 121 for unreleased changes, and a newly created section otherwise. 122 To assemble the changelog after modifying top_version_body, 123 concatenate the four pieces. 124 """ 125 raise NotImplementedError 126 127 @classmethod 128 def version_title_text(cls, version_title): 129 """Return the text of a formatted version section title.""" 130 raise NotImplementedError 131 132 @classmethod 133 def split_categories(cls, version_body): 134 """Split a changelog version section body into categories. 135 136 Return a list of `CategoryContent` the name is category title 137 without any formatting. 138 """ 139 raise NotImplementedError 140 141 @classmethod 142 def format_category(cls, title, body): 143 """Construct the text of a category section from its title and body.""" 144 raise NotImplementedError 145 146class TextChangelogFormat(ChangelogFormat): 147 """The traditional Mbed TLS changelog format.""" 148 149 _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx' 150 @classmethod 151 def is_released_version(cls, title): 152 # Look for an incomplete release date 153 return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title) 154 155 _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)', 156 re.DOTALL) 157 @classmethod 158 def extract_top_version(cls, changelog_file_content): 159 """A version section starts with a line starting with '='.""" 160 m = re.search(cls._top_version_re, changelog_file_content) 161 top_version_start = m.start(1) 162 top_version_end = m.end(2) 163 top_version_title = m.group(1) 164 top_version_body = m.group(2) 165 if cls.is_released_version(top_version_title): 166 top_version_end = top_version_start 167 top_version_title = cls._unreleased_version_text + b'\n\n' 168 top_version_body = b'' 169 return (changelog_file_content[:top_version_start], 170 top_version_title, top_version_body, 171 changelog_file_content[top_version_end:]) 172 173 @classmethod 174 def version_title_text(cls, version_title): 175 return re.sub(br'\n.*', version_title, re.DOTALL) 176 177 _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE) 178 @classmethod 179 def split_categories(cls, version_body): 180 """A category title is a line with the title in column 0.""" 181 if not version_body: 182 return [] 183 title_matches = list(re.finditer(cls._category_title_re, version_body)) 184 if not title_matches or title_matches[0].start() != 0: 185 # There is junk before the first category. 186 raise CategoryParseError(0, 'Junk found where category expected') 187 title_starts = [m.start(1) for m in title_matches] 188 body_starts = [m.end(0) for m in title_matches] 189 body_ends = title_starts[1:] + [len(version_body)] 190 bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n' 191 for (body_start, body_end) in zip(body_starts, body_ends)] 192 title_lines = [version_body[:pos].count(b'\n') for pos in title_starts] 193 body_lines = [version_body[:pos].count(b'\n') for pos in body_starts] 194 return [CategoryContent(title_match.group(1), title_line, 195 body, body_line) 196 for title_match, title_line, body, body_line 197 in zip(title_matches, title_lines, bodies, body_lines)] 198 199 @classmethod 200 def format_category(cls, title, body): 201 # `split_categories` ensures that each body ends with a newline. 202 # Make sure that there is additionally a blank line between categories. 203 if not body.endswith(b'\n\n'): 204 body += b'\n' 205 return title + b'\n' + body 206 207class ChangeLog: 208 """An Mbed TLS changelog. 209 210 A changelog file consists of some header text followed by one or 211 more version sections. The version sections are in reverse 212 chronological order. Each version section consists of a title and a body. 213 214 The body of a version section consists of zero or more category 215 subsections. Each category subsection consists of a title and a body. 216 217 A changelog entry file has the same format as the body of a version section. 218 219 A `ChangelogFormat` object defines the concrete syntax of the changelog. 220 Entry files must have the same format as the changelog file. 221 """ 222 223 # Only accept dotted version numbers (e.g. "3.1", not "3"). 224 # Refuse ".x" in a version number where x is a letter: this indicates 225 # a version that is not yet released. Something like "3.1a" is accepted. 226 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+') 227 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]') 228 229 def add_categories_from_text(self, filename, line_offset, 230 text, allow_unknown_category): 231 """Parse a version section or entry file.""" 232 try: 233 categories = self.format.split_categories(text) 234 except CategoryParseError as e: 235 raise InputFormatError(filename, line_offset + e.line_offset, 236 e.error_message) 237 for category in categories: 238 if not allow_unknown_category and \ 239 category.name not in self.categories: 240 raise InputFormatError(filename, 241 line_offset + category.title_line, 242 'Unknown category: "{}"', 243 category.name.decode('utf8')) 244 self.categories[category.name] += category.body 245 246 def __init__(self, input_stream, changelog_format): 247 """Create a changelog object. 248 249 Populate the changelog object from the content of the file 250 input_stream. 251 """ 252 self.format = changelog_format 253 whole_file = input_stream.read() 254 (self.header, 255 self.top_version_title, top_version_body, 256 self.trailer) = self.format.extract_top_version(whole_file) 257 # Split the top version section into categories. 258 self.categories = OrderedDict() 259 for category in STANDARD_CATEGORIES: 260 self.categories[category] = b'' 261 offset = (self.header + self.top_version_title).count(b'\n') + 1 262 self.add_categories_from_text(input_stream.name, offset, 263 top_version_body, True) 264 265 def add_file(self, input_stream): 266 """Add changelog entries from a file. 267 """ 268 self.add_categories_from_text(input_stream.name, 1, 269 input_stream.read(), False) 270 271 def write(self, filename): 272 """Write the changelog to the specified file. 273 """ 274 with open(filename, 'wb') as out: 275 out.write(self.header) 276 out.write(self.top_version_title) 277 for title, body in self.categories.items(): 278 if not body: 279 continue 280 out.write(self.format.format_category(title, body)) 281 out.write(self.trailer) 282 283 284@functools.total_ordering 285class EntryFileSortKey: 286 """This classes defines an ordering on changelog entry files: older < newer. 287 288 * Merged entry files are sorted according to their merge date (date of 289 the merge commit that brought the commit that created the file into 290 the target branch). 291 * Committed but unmerged entry files are sorted according to the date 292 of the commit that adds them. 293 * Uncommitted entry files are sorted according to their modification time. 294 295 This class assumes that the file is in a git working directory with 296 the target branch checked out. 297 """ 298 299 # Categories of files. A lower number is considered older. 300 MERGED = 0 301 COMMITTED = 1 302 LOCAL = 2 303 304 @staticmethod 305 def creation_hash(filename): 306 """Return the git commit id at which the given file was created. 307 308 Return None if the file was never checked into git. 309 """ 310 hashes = subprocess.check_output(['git', 'log', '--format=%H', 311 '--follow', 312 '--', filename]) 313 m = re.search(b'(.+)$', hashes) 314 if not m: 315 # The git output is empty. This means that the file was 316 # never checked in. 317 return None 318 # The last commit in the log is the oldest one, which is when the 319 # file was created. 320 return m.group(0) 321 322 @staticmethod 323 def list_merges(some_hash, target, *options): 324 """List merge commits from some_hash to target. 325 326 Pass options to git to select which commits are included. 327 """ 328 text = subprocess.check_output(['git', 'rev-list', 329 '--merges', *options, 330 b'..'.join([some_hash, target])]) 331 return text.rstrip(b'\n').split(b'\n') 332 333 @classmethod 334 def merge_hash(cls, some_hash): 335 """Return the git commit id at which the given commit was merged. 336 337 Return None if the given commit was never merged. 338 """ 339 target = b'HEAD' 340 # List the merges from some_hash to the target in two ways. 341 # The ancestry list is the ones that are both descendants of 342 # some_hash and ancestors of the target. 343 ancestry = frozenset(cls.list_merges(some_hash, target, 344 '--ancestry-path')) 345 # The first_parents list only contains merges that are directly 346 # on the target branch. We want it in reverse order (oldest first). 347 first_parents = cls.list_merges(some_hash, target, 348 '--first-parent', '--reverse') 349 # Look for the oldest merge commit that's both on the direct path 350 # and directly on the target branch. That's the place where some_hash 351 # was merged on the target branch. See 352 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit 353 for commit in first_parents: 354 if commit in ancestry: 355 return commit 356 return None 357 358 @staticmethod 359 def commit_timestamp(commit_id): 360 """Return the timestamp of the given commit.""" 361 text = subprocess.check_output(['git', 'show', '-s', 362 '--format=%ct', 363 commit_id]) 364 return datetime.datetime.utcfromtimestamp(int(text)) 365 366 @staticmethod 367 def file_timestamp(filename): 368 """Return the modification timestamp of the given file.""" 369 mtime = os.stat(filename).st_mtime 370 return datetime.datetime.fromtimestamp(mtime) 371 372 def __init__(self, filename): 373 """Determine position of the file in the changelog entry order. 374 375 This constructor returns an object that can be used with comparison 376 operators, with `sort` and `sorted`, etc. Older entries are sorted 377 before newer entries. 378 """ 379 self.filename = filename 380 creation_hash = self.creation_hash(filename) 381 if not creation_hash: 382 self.category = self.LOCAL 383 self.datetime = self.file_timestamp(filename) 384 return 385 merge_hash = self.merge_hash(creation_hash) 386 if not merge_hash: 387 self.category = self.COMMITTED 388 self.datetime = self.commit_timestamp(creation_hash) 389 return 390 self.category = self.MERGED 391 self.datetime = self.commit_timestamp(merge_hash) 392 393 def sort_key(self): 394 """"Return a concrete sort key for this entry file sort key object. 395 396 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``. 397 """ 398 return (self.category, self.datetime, self.filename) 399 400 def __eq__(self, other): 401 return self.sort_key() == other.sort_key() 402 403 def __lt__(self, other): 404 return self.sort_key() < other.sort_key() 405 406 407def check_output(generated_output_file, main_input_file, merged_files): 408 """Make sanity checks on the generated output. 409 410 The intent of these sanity checks is to have reasonable confidence 411 that no content has been lost. 412 413 The sanity check is that every line that is present in an input file 414 is also present in an output file. This is not perfect but good enough 415 for now. 416 """ 417 generated_output = set(open(generated_output_file, 'rb')) 418 for line in open(main_input_file, 'rb'): 419 if line not in generated_output: 420 raise LostContent('original file', line) 421 for merged_file in merged_files: 422 for line in open(merged_file, 'rb'): 423 if line not in generated_output: 424 raise LostContent(merged_file, line) 425 426def finish_output(changelog, output_file, input_file, merged_files): 427 """Write the changelog to the output file. 428 429 The input file and the list of merged files are used only for sanity 430 checks on the output. 431 """ 432 if os.path.exists(output_file) and not os.path.isfile(output_file): 433 # The output is a non-regular file (e.g. pipe). Write to it directly. 434 output_temp = output_file 435 else: 436 # The output is a regular file. Write to a temporary file, 437 # then move it into place atomically. 438 output_temp = output_file + '.tmp' 439 changelog.write(output_temp) 440 check_output(output_temp, input_file, merged_files) 441 if output_temp != output_file: 442 os.rename(output_temp, output_file) 443 444def remove_merged_entries(files_to_remove): 445 for filename in files_to_remove: 446 os.remove(filename) 447 448def list_files_to_merge(options): 449 """List the entry files to merge, oldest first. 450 451 "Oldest" is defined by `EntryFileSortKey`. 452 """ 453 files_to_merge = glob.glob(os.path.join(options.dir, '*.txt')) 454 files_to_merge.sort(key=EntryFileSortKey) 455 return files_to_merge 456 457def merge_entries(options): 458 """Merge changelog entries into the changelog file. 459 460 Read the changelog file from options.input. 461 Read entries to merge from the directory options.dir. 462 Write the new changelog to options.output. 463 Remove the merged entries if options.keep_entries is false. 464 """ 465 with open(options.input, 'rb') as input_file: 466 changelog = ChangeLog(input_file, TextChangelogFormat) 467 files_to_merge = list_files_to_merge(options) 468 if not files_to_merge: 469 sys.stderr.write('There are no pending changelog entries.\n') 470 return 471 for filename in files_to_merge: 472 with open(filename, 'rb') as input_file: 473 changelog.add_file(input_file) 474 finish_output(changelog, options.output, options.input, files_to_merge) 475 if not options.keep_entries: 476 remove_merged_entries(files_to_merge) 477 478def show_file_timestamps(options): 479 """List the files to merge and their timestamp. 480 481 This is only intended for debugging purposes. 482 """ 483 files = list_files_to_merge(options) 484 for filename in files: 485 ts = EntryFileSortKey(filename) 486 print(ts.category, ts.datetime, filename) 487 488def set_defaults(options): 489 """Add default values for missing options.""" 490 output_file = getattr(options, 'output', None) 491 if output_file is None: 492 options.output = options.input 493 if getattr(options, 'keep_entries', None) is None: 494 options.keep_entries = (output_file is not None) 495 496def main(): 497 """Command line entry point.""" 498 parser = argparse.ArgumentParser(description=__doc__) 499 parser.add_argument('--dir', '-d', metavar='DIR', 500 default='ChangeLog.d', 501 help='Directory to read entries from' 502 ' (default: ChangeLog.d)') 503 parser.add_argument('--input', '-i', metavar='FILE', 504 default='ChangeLog', 505 help='Existing changelog file to read from and augment' 506 ' (default: ChangeLog)') 507 parser.add_argument('--keep-entries', 508 action='store_true', dest='keep_entries', default=None, 509 help='Keep the files containing entries' 510 ' (default: remove them if --output/-o is not specified)') 511 parser.add_argument('--no-keep-entries', 512 action='store_false', dest='keep_entries', 513 help='Remove the files containing entries after they are merged' 514 ' (default: remove them if --output/-o is not specified)') 515 parser.add_argument('--output', '-o', metavar='FILE', 516 help='Output changelog file' 517 ' (default: overwrite the input)') 518 parser.add_argument('--list-files-only', 519 action='store_true', 520 help=('Only list the files that would be processed ' 521 '(with some debugging information)')) 522 options = parser.parse_args() 523 set_defaults(options) 524 if options.list_files_only: 525 show_file_timestamps(options) 526 return 527 merge_entries(options) 528 529if __name__ == '__main__': 530 main() 531