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