blob: 81ce6822329878033e67b7b90595599803351f7f [file] [log] [blame]
Gilles Peskine40b3f412019-10-13 21:44:25 +02001#!/usr/bin/env python3
2
Gilles Peskine42f384c2020-03-27 09:23:38 +01003"""Assemble Mbed TLS change log entries into the change log file.
Gilles Peskinea2607962020-01-28 19:58:17 +01004
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.
Gilles Peskine28af9582020-03-26 22:39:18 +01008
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.
Gilles Peskine40b3f412019-10-13 21:44:25 +020019"""
20
Bence Szépkúti1e148272020-08-07 13:07:28 +020021# Copyright The Mbed TLS Contributors
Dave Rodgman16799db2023-11-02 19:47:20 +000022# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
Gilles Peskine40b3f412019-10-13 21:44:25 +020023
24import argparse
Gilles Peskine6e97c432020-03-27 19:05:18 +010025from collections import OrderedDict, namedtuple
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010026import datetime
27import functools
Gilles Peskine40b3f412019-10-13 21:44:25 +020028import glob
29import os
30import re
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010031import subprocess
Gilles Peskine40b3f412019-10-13 21:44:25 +020032import sys
33
34class InputFormatError(Exception):
35 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010036 message = '{}:{}: {}'.format(filename, line_number,
37 message.format(*args, **kwargs))
38 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020039
Gilles Peskine4d977a42020-03-27 19:42:50 +010040class CategoryParseError(Exception):
41 def __init__(self, line_offset, error_message):
42 self.line_offset = line_offset
43 self.error_message = error_message
44 super().__init__('{}: {}'.format(line_offset, error_message))
45
Gilles Peskine2b242492020-01-22 15:41:50 +010046class LostContent(Exception):
47 def __init__(self, filename, line):
48 message = ('Lost content from {}: "{}"'.format(filename, line))
49 super().__init__(message)
50
Dave Rodgman3901e2e2023-10-02 16:40:57 +010051class FilePathError(Exception):
52 def __init__(self, filenames):
53 message = ('Changelog filenames do not end with .txt: {}'.format(", ".join(filenames)))
54 super().__init__(message)
55
Gilles Peskineb695d5e2020-03-27 20:06:12 +010056# The category names we use in the changelog.
57# If you edit this, update ChangeLog.d/README.md.
Gilles Peskine6e97c432020-03-27 19:05:18 +010058STANDARD_CATEGORIES = (
Gilles Peskine7261fff2021-05-18 14:39:40 +020059 'API changes',
60 'Default behavior changes',
61 'Requirement changes',
62 'New deprecations',
63 'Removals',
64 'Features',
65 'Security',
66 'Bugfix',
67 'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020068)
69
Paul Elliottf08648d2021-03-05 12:22:51 +000070# The maximum line length for an entry
71MAX_LINE_LENGTH = 80
72
Gilles Peskine6e97c432020-03-27 19:05:18 +010073CategoryContent = namedtuple('CategoryContent', [
74 'name', 'title_line', # Title text and line number of the title
75 'body', 'body_line', # Body text and starting line number of the body
76])
77
78class ChangelogFormat:
79 """Virtual class documenting how to write a changelog format class."""
80
81 @classmethod
82 def extract_top_version(cls, changelog_file_content):
83 """Split out the top version section.
84
Gilles Peskineeebf24f2020-03-27 19:25:38 +010085 If the top version is already released, create a new top
86 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020087
88 Return ``(header, top_version_title, top_version_body, trailer)``
89 where the "top version" is the existing top version section if it's
90 for unreleased changes, and a newly created section otherwise.
91 To assemble the changelog after modifying top_version_body,
92 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +010093 """
94 raise NotImplementedError
95
96 @classmethod
97 def version_title_text(cls, version_title):
98 """Return the text of a formatted version section title."""
99 raise NotImplementedError
100
101 @classmethod
102 def split_categories(cls, version_body):
103 """Split a changelog version section body into categories.
104
105 Return a list of `CategoryContent` the name is category title
106 without any formatting.
107 """
108 raise NotImplementedError
109
110 @classmethod
111 def format_category(cls, title, body):
112 """Construct the text of a category section from its title and body."""
113 raise NotImplementedError
114
115class TextChangelogFormat(ChangelogFormat):
116 """The traditional Mbed TLS changelog format."""
117
Gabor Mezei8933c042023-11-21 17:05:43 +0100118 _unreleased_version_text = '= {} x.x.x branch released xxxx-xx-xx'
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100119 @classmethod
120 def is_released_version(cls, title):
121 # Look for an incomplete release date
Gilles Peskine7261fff2021-05-18 14:39:40 +0200122 return not re.search(r'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100123
Gilles Peskine7261fff2021-05-18 14:39:40 +0200124 _top_version_re = re.compile(r'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100125 re.DOTALL)
Gabor Mezei8933c042023-11-21 17:05:43 +0100126 _name_re = re.compile(r'=\s(.*)\s[0-9x]+\.', re.DOTALL)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100127 @classmethod
128 def extract_top_version(cls, changelog_file_content):
129 """A version section starts with a line starting with '='."""
130 m = re.search(cls._top_version_re, changelog_file_content)
Gabor Mezeiddffa102023-11-21 17:03:29 +0100131 if m:
132 top_version_start = m.start(1)
133 top_version_end = m.end(2)
134 top_version_title = m.group(1)
135 top_version_body = m.group(2)
Gabor Mezei8933c042023-11-21 17:05:43 +0100136 name = re.match(cls._name_re, top_version_title).group(1)
Gabor Mezeiddffa102023-11-21 17:03:29 +0100137 # No entries found
138 else:
139 top_version_start = None
140 top_version_end = None
Gabor Mezei8933c042023-11-21 17:05:43 +0100141 name = 'xxx'
Gabor Mezeiddffa102023-11-21 17:03:29 +0100142 top_version_title = ''
143 top_version_body = ''
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100144 if cls.is_released_version(top_version_title):
145 top_version_end = top_version_start
Gabor Mezei8933c042023-11-21 17:05:43 +0100146 top_version_title = cls._unreleased_version_text.format(name) + '\n\n'
Gilles Peskine7261fff2021-05-18 14:39:40 +0200147 top_version_body = ''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100148 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100149 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100150 changelog_file_content[top_version_end:])
151
152 @classmethod
153 def version_title_text(cls, version_title):
Gilles Peskine7261fff2021-05-18 14:39:40 +0200154 return re.sub(r'\n.*', version_title, re.DOTALL)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100155
Gilles Peskine7261fff2021-05-18 14:39:40 +0200156 _category_title_re = re.compile(r'(^\w.*)\n+', re.MULTILINE)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100157 @classmethod
158 def split_categories(cls, version_body):
159 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100160 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100161 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100162 title_matches = list(re.finditer(cls._category_title_re, version_body))
163 if not title_matches or title_matches[0].start() != 0:
164 # There is junk before the first category.
165 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100166 title_starts = [m.start(1) for m in title_matches]
167 body_starts = [m.end(0) for m in title_matches]
168 body_ends = title_starts[1:] + [len(version_body)]
Gilles Peskine7261fff2021-05-18 14:39:40 +0200169 bodies = [version_body[body_start:body_end].rstrip('\n') + '\n'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100170 for (body_start, body_end) in zip(body_starts, body_ends)]
Gilles Peskine7261fff2021-05-18 14:39:40 +0200171 title_lines = [version_body[:pos].count('\n') for pos in title_starts]
172 body_lines = [version_body[:pos].count('\n') for pos in body_starts]
Gilles Peskine6e97c432020-03-27 19:05:18 +0100173 return [CategoryContent(title_match.group(1), title_line,
174 body, body_line)
175 for title_match, title_line, body, body_line
176 in zip(title_matches, title_lines, bodies, body_lines)]
177
178 @classmethod
179 def format_category(cls, title, body):
180 # `split_categories` ensures that each body ends with a newline.
181 # Make sure that there is additionally a blank line between categories.
Gilles Peskine7261fff2021-05-18 14:39:40 +0200182 if not body.endswith('\n\n'):
183 body += '\n'
184 return title + '\n' + body
Gilles Peskine6e97c432020-03-27 19:05:18 +0100185
Gilles Peskine40b3f412019-10-13 21:44:25 +0200186class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100187 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200188
Gilles Peskine6e97c432020-03-27 19:05:18 +0100189 A changelog file consists of some header text followed by one or
190 more version sections. The version sections are in reverse
191 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200192
Gilles Peskine6e97c432020-03-27 19:05:18 +0100193 The body of a version section consists of zero or more category
194 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200195
Gilles Peskine6e97c432020-03-27 19:05:18 +0100196 A changelog entry file has the same format as the body of a version section.
197
198 A `ChangelogFormat` object defines the concrete syntax of the changelog.
199 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200200 """
201
Gilles Peskinea2607962020-01-28 19:58:17 +0100202 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100203 # Refuse ".x" in a version number where x is a letter: this indicates
204 # a version that is not yet released. Something like "3.1a" is accepted.
Gilles Peskine7261fff2021-05-18 14:39:40 +0200205 _version_number_re = re.compile(r'[0-9]+\.[0-9A-Za-z.]+')
206 _incomplete_version_number_re = re.compile(r'.*\.[A-Za-z]')
207 _only_url_re = re.compile(r'^\s*\w+://\S+\s*$')
208 _has_url_re = re.compile(r'.*://.*')
Gilles Peskinea2607962020-01-28 19:58:17 +0100209
Gilles Peskine6e97c432020-03-27 19:05:18 +0100210 def add_categories_from_text(self, filename, line_offset,
211 text, allow_unknown_category):
212 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100213 try:
214 categories = self.format.split_categories(text)
215 except CategoryParseError as e:
216 raise InputFormatError(filename, line_offset + e.line_offset,
217 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100218 for category in categories:
219 if not allow_unknown_category and \
220 category.name not in self.categories:
221 raise InputFormatError(filename,
222 line_offset + category.title_line,
223 'Unknown category: "{}"',
Gilles Peskine7261fff2021-05-18 14:39:40 +0200224 category.name)
Paul Elliottf08648d2021-03-05 12:22:51 +0000225
226 body_split = category.body.splitlines()
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200227
Paul Elliottd75773e2021-03-18 18:07:46 +0000228 for line_number, line in enumerate(body_split, 1):
Mateusz Starzyk3cfed582021-03-31 11:09:21 +0200229 if not self._only_url_re.match(line) and \
Mateusz Starzyk6e470552021-03-24 12:13:33 +0100230 len(line) > MAX_LINE_LENGTH:
Mateusz Starzyk9b31ad62021-03-31 11:18:28 +0200231 long_url_msg = '. URL exceeding length limit must be alone in its line.' \
232 if self._has_url_re.match(line) else ""
Paul Elliottf08648d2021-03-05 12:22:51 +0000233 raise InputFormatError(filename,
Paul Elliottd75773e2021-03-18 18:07:46 +0000234 category.body_line + line_number,
Mateusz Starzykc8f44892021-03-25 14:06:50 +0100235 'Line is longer than allowed: '
236 'Length {} (Max {}){}',
237 len(line), MAX_LINE_LENGTH,
238 long_url_msg)
Paul Elliottf08648d2021-03-05 12:22:51 +0000239
Gilles Peskine6e97c432020-03-27 19:05:18 +0100240 self.categories[category.name] += category.body
241
242 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200243 """Create a changelog object.
244
Gilles Peskine974232f2020-01-22 12:43:29 +0100245 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100246 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200247 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100248 self.format = changelog_format
249 whole_file = input_stream.read()
250 (self.header,
251 self.top_version_title, top_version_body,
252 self.trailer) = self.format.extract_top_version(whole_file)
253 # Split the top version section into categories.
254 self.categories = OrderedDict()
255 for category in STANDARD_CATEGORIES:
Gilles Peskine7261fff2021-05-18 14:39:40 +0200256 self.categories[category] = ''
Gabor Mezeiddffa102023-11-21 17:03:29 +0100257 if self.header:
258 offset = (self.header + self.top_version_title).count('\n') + 1
259 else:
260 offset = 0
261
Gilles Peskine6e97c432020-03-27 19:05:18 +0100262 self.add_categories_from_text(input_stream.name, offset,
263 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200264
265 def add_file(self, input_stream):
266 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200267 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100268 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100269 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200270
271 def write(self, filename):
272 """Write the changelog to the specified file.
273 """
Gilles Peskinee151e212021-05-18 14:49:02 +0200274 with open(filename, 'w', encoding='utf-8') as out:
Gabor Mezeiddffa102023-11-21 17:03:29 +0100275 if self.header:
276 out.write(self.header)
277 if self.top_version_title:
278 out.write(self.top_version_title)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100279 for title, body in self.categories.items():
280 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200281 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100282 out.write(self.format.format_category(title, body))
283 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200284
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100285
286@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100287class EntryFileSortKey:
288 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100289
Gilles Peskine28af9582020-03-26 22:39:18 +0100290 * Merged entry files are sorted according to their merge date (date of
291 the merge commit that brought the commit that created the file into
292 the target branch).
293 * Committed but unmerged entry files are sorted according to the date
294 of the commit that adds them.
295 * Uncommitted entry files are sorted according to their modification time.
296
297 This class assumes that the file is in a git working directory with
298 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100299 """
300
301 # Categories of files. A lower number is considered older.
302 MERGED = 0
303 COMMITTED = 1
304 LOCAL = 2
305
306 @staticmethod
307 def creation_hash(filename):
308 """Return the git commit id at which the given file was created.
309
310 Return None if the file was never checked into git.
311 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100312 hashes = subprocess.check_output(['git', 'log', '--format=%H',
313 '--follow',
314 '--', filename])
Gilles Peskine7261fff2021-05-18 14:39:40 +0200315 m = re.search('(.+)$', hashes.decode('ascii'))
Gilles Peskine13dc6342020-03-26 22:46:47 +0100316 if not m:
317 # The git output is empty. This means that the file was
318 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100319 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100320 # The last commit in the log is the oldest one, which is when the
321 # file was created.
322 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100323
324 @staticmethod
325 def list_merges(some_hash, target, *options):
326 """List merge commits from some_hash to target.
327
328 Pass options to git to select which commits are included.
329 """
330 text = subprocess.check_output(['git', 'rev-list',
331 '--merges', *options,
Gilles Peskine7261fff2021-05-18 14:39:40 +0200332 '..'.join([some_hash, target])])
333 return text.decode('ascii').rstrip('\n').split('\n')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100334
335 @classmethod
336 def merge_hash(cls, some_hash):
337 """Return the git commit id at which the given commit was merged.
338
339 Return None if the given commit was never merged.
340 """
Gilles Peskine7261fff2021-05-18 14:39:40 +0200341 target = 'HEAD'
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100342 # List the merges from some_hash to the target in two ways.
343 # The ancestry list is the ones that are both descendants of
344 # some_hash and ancestors of the target.
345 ancestry = frozenset(cls.list_merges(some_hash, target,
346 '--ancestry-path'))
347 # The first_parents list only contains merges that are directly
348 # on the target branch. We want it in reverse order (oldest first).
349 first_parents = cls.list_merges(some_hash, target,
350 '--first-parent', '--reverse')
351 # Look for the oldest merge commit that's both on the direct path
352 # and directly on the target branch. That's the place where some_hash
353 # was merged on the target branch. See
354 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
355 for commit in first_parents:
356 if commit in ancestry:
357 return commit
358 return None
359
360 @staticmethod
361 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100362 """Return the timestamp of the given commit."""
363 text = subprocess.check_output(['git', 'show', '-s',
364 '--format=%ct',
365 commit_id])
366 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100367
368 @staticmethod
369 def file_timestamp(filename):
370 """Return the modification timestamp of the given file."""
371 mtime = os.stat(filename).st_mtime
372 return datetime.datetime.fromtimestamp(mtime)
373
374 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100375 """Determine position of the file in the changelog entry order.
376
377 This constructor returns an object that can be used with comparison
378 operators, with `sort` and `sorted`, etc. Older entries are sorted
379 before newer entries.
380 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100381 self.filename = filename
382 creation_hash = self.creation_hash(filename)
383 if not creation_hash:
384 self.category = self.LOCAL
385 self.datetime = self.file_timestamp(filename)
386 return
387 merge_hash = self.merge_hash(creation_hash)
388 if not merge_hash:
389 self.category = self.COMMITTED
390 self.datetime = self.commit_timestamp(creation_hash)
391 return
392 self.category = self.MERGED
393 self.datetime = self.commit_timestamp(merge_hash)
394
395 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100396 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100397
Gilles Peskine28af9582020-03-26 22:39:18 +0100398 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100399 """
400 return (self.category, self.datetime, self.filename)
401
402 def __eq__(self, other):
403 return self.sort_key() == other.sort_key()
404
405 def __lt__(self, other):
406 return self.sort_key() < other.sort_key()
407
408
Gilles Peskine2b242492020-01-22 15:41:50 +0100409def check_output(generated_output_file, main_input_file, merged_files):
410 """Make sanity checks on the generated output.
411
412 The intent of these sanity checks is to have reasonable confidence
413 that no content has been lost.
414
415 The sanity check is that every line that is present in an input file
416 is also present in an output file. This is not perfect but good enough
417 for now.
418 """
Gilles Peskinedcf2ff52022-03-04 20:02:00 +0100419 with open(generated_output_file, 'r', encoding='utf-8') as fd:
420 generated_output = set(fd)
421 for line in open(main_input_file, 'r', encoding='utf-8'):
Gilles Peskine2b242492020-01-22 15:41:50 +0100422 if line not in generated_output:
Gilles Peskinedcf2ff52022-03-04 20:02:00 +0100423 raise LostContent('original file', line)
424 for merged_file in merged_files:
425 for line in open(merged_file, 'r', encoding='utf-8'):
426 if line not in generated_output:
427 raise LostContent(merged_file, line)
Gilles Peskine2b242492020-01-22 15:41:50 +0100428
429def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200430 """Write the changelog to the output file.
431
Gilles Peskine2b242492020-01-22 15:41:50 +0100432 The input file and the list of merged files are used only for sanity
433 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200434 """
435 if os.path.exists(output_file) and not os.path.isfile(output_file):
436 # The output is a non-regular file (e.g. pipe). Write to it directly.
437 output_temp = output_file
438 else:
439 # The output is a regular file. Write to a temporary file,
440 # then move it into place atomically.
441 output_temp = output_file + '.tmp'
442 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100443 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200444 if output_temp != output_file:
445 os.rename(output_temp, output_file)
446
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100447def remove_merged_entries(files_to_remove):
448 for filename in files_to_remove:
449 os.remove(filename)
450
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100451def list_files_to_merge(options):
452 """List the entry files to merge, oldest first.
453
Gilles Peskine28af9582020-03-26 22:39:18 +0100454 "Oldest" is defined by `EntryFileSortKey`.
Dave Rodgman65d8ec12023-10-02 17:19:51 +0100455
456 Also check for required .txt extension
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100457 """
Dave Rodgman65d8ec12023-10-02 17:19:51 +0100458 files_to_merge = glob.glob(os.path.join(options.dir, '*'))
459
460 # Ignore 00README.md
461 readme = os.path.join(options.dir, "00README.md")
462 if readme in files_to_merge:
463 files_to_merge.remove(readme)
464
465 # Identify files without the required .txt extension
466 bad_files = [x for x in files_to_merge if not x.endswith(".txt")]
467 if bad_files:
468 raise FilePathError(bad_files)
469
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100470 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100471 return files_to_merge
472
Gilles Peskine40b3f412019-10-13 21:44:25 +0200473def merge_entries(options):
474 """Merge changelog entries into the changelog file.
475
476 Read the changelog file from options.input.
Dave Rodgman3901e2e2023-10-02 16:40:57 +0100477 Check that all entries have a .txt extension
Gilles Peskine40b3f412019-10-13 21:44:25 +0200478 Read entries to merge from the directory options.dir.
479 Write the new changelog to options.output.
480 Remove the merged entries if options.keep_entries is false.
481 """
Gilles Peskinee151e212021-05-18 14:49:02 +0200482 with open(options.input, 'r', encoding='utf-8') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100483 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100484 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200485 if not files_to_merge:
486 sys.stderr.write('There are no pending changelog entries.\n')
487 return
488 for filename in files_to_merge:
Gilles Peskinee151e212021-05-18 14:49:02 +0200489 with open(filename, 'r', encoding='utf-8') as input_file:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200490 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100491 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100492 if not options.keep_entries:
493 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200494
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100495def show_file_timestamps(options):
496 """List the files to merge and their timestamp.
497
498 This is only intended for debugging purposes.
499 """
500 files = list_files_to_merge(options)
501 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100502 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100503 print(ts.category, ts.datetime, filename)
504
Gilles Peskine40b3f412019-10-13 21:44:25 +0200505def set_defaults(options):
506 """Add default values for missing options."""
507 output_file = getattr(options, 'output', None)
508 if output_file is None:
509 options.output = options.input
510 if getattr(options, 'keep_entries', None) is None:
511 options.keep_entries = (output_file is not None)
512
513def main():
514 """Command line entry point."""
515 parser = argparse.ArgumentParser(description=__doc__)
516 parser.add_argument('--dir', '-d', metavar='DIR',
517 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100518 help='Directory to read entries from'
519 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200520 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100521 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100522 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100523 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200524 parser.add_argument('--keep-entries',
525 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100526 help='Keep the files containing entries'
527 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200528 parser.add_argument('--no-keep-entries',
529 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100530 help='Remove the files containing entries after they are merged'
531 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200532 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100533 help='Output changelog file'
534 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100535 parser.add_argument('--list-files-only',
536 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100537 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100538 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200539 options = parser.parse_args()
540 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100541 if options.list_files_only:
542 show_file_timestamps(options)
543 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200544 merge_entries(options)
545
546if __name__ == '__main__':
547 main()